纯净度改为只查自己:锁定 IPv4 / IPv6 连接分别请求 cleanip.io/cli?json=1
cleanip.io 自 09-15 起不再接受 /cli?ip=,带参数一律 400,网络详情的纯净度因此一直停在“正在查询”。 - 新增 AddressFamilyRequest:用 Network.framework 建连接并锁定 IP 版本,走 HTTP/1.1 over TLS 请求, 处理 chunked 与 Content-Length;这一族没有路由时直接失败,不干等。URLSession 无法指定地址族, 而 cleanip.io 在 Cloudflare 后面 A / AAAA 都有,锁定地址族才能两族各查到自己的结果 - 锁定地址族的连接建不起来时退回 URLSession,只接受与所查同一族的结果 - 分流代理下 cleanip.io 看到的出口与 Cloudflare 不同时,显示 cleanip.io 看到的地址;缓存按 Cloudflare 的地址比对 - 查询失败的空结果不再写进 7 天缓存,也不进内存缓存,服务恢复后下次打开详情即重查 - 单元测试覆盖响应解析;真实网络下 IPv4 在本机验证、IPv6 在服务器上用 curl -6 验证 cleanip.io 返回对应地址的纯净度
This commit is contained in:
1 parent
504b6eaeab
commit
d305e5e3e1
11 files changed
+255
-66
No files matched your search
+8
-3
@@ -158,9 +158,14 @@ is open and back to accessory when all are closed.
|
|||||||
- **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical
|
- **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical
|
||||||
service, `getifaddrs` for addresses, CoreWLAN for signal and rate.
|
service, `getifaddrs` for addresses, CoreWLAN for signal and rate.
|
||||||
- **Public IP** — Cloudflare trace (ipify as fallback) for the address only. Location, ASN, network
|
- **Public IP** — Cloudflare trace (ipify as fallback) for the address only. Location, ASN, network
|
||||||
type, native / broadcast and the cleanliness score come from `cleanip.io`, queried directly from each
|
type, native / broadcast and the cleanliness score come from `cleanip.io/cli?json=1`, queried directly
|
||||||
Mac by `PublicAddressLookup` (one request per address family; results are cached per IP for an hour
|
from each Mac by `PublicAddressLookup`. The endpoint only reports the caller's own address, so
|
||||||
in memory and for a week on disk, and a 429 stops further calls until the next UTC day). Country
|
`AddressFamilyRequest` opens one Network.framework connection pinned to IPv4 and one pinned to IPv6
|
||||||
|
(URLSession cannot choose the family) and speaks plain HTTP/1.1 over TLS; when a pinned connection
|
||||||
|
cannot be set up it falls back to URLSession and keeps the answer only if it is for the same family.
|
||||||
|
If cleanip.io sees a different exit than Cloudflare (split-routing proxies), its address is shown.
|
||||||
|
Results are cached per IP for an hour in memory and for a week on disk (failed lookups are not
|
||||||
|
cached), and a 429 stops further calls until the next UTC day. Country
|
||||||
codes are validated before being used as flag file names. `server/geoip/` still holds the systemd
|
codes are validated before being used as flag file names. `server/geoip/` still holds the systemd
|
||||||
timer that syncs MaxMind GeoLite2 onto `getopenstats.com/geoip/` (account and key in
|
timer that syncs MaxMind GeoLite2 onto `getopenstats.com/geoip/` (account and key in
|
||||||
`/etc/openstats/maxmind.env` on the server); the app no longer downloads those files, they are kept
|
`/etc/openstats/maxmind.env` on the server); the app no longer downloads those files, they are kept
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
|
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
|
||||||
<title>44 commits in the last 26 weeks</title>
|
<title>56 commits in the last 26 weeks</title>
|
||||||
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
|
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
|
||||||
<text x="58" y="12">Apr</text>
|
<text x="58" y="12">Apr</text>
|
||||||
<text x="114" y="12">May</text>
|
<text x="114" y="12">May</text>
|
||||||
@@ -188,8 +188,9 @@
|
|||||||
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>0 commits on 2026-09-12</title></rect>
|
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>0 commits on 2026-09-12</title></rect>
|
||||||
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>42 commits on 2026-09-13</title></rect>
|
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>42 commits on 2026-09-13</title></rect>
|
||||||
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2 commits on 2026-09-14</title></rect>
|
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2 commits on 2026-09-14</title></rect>
|
||||||
|
<rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>12 commits on 2026-09-15</title></rect>
|
||||||
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
|
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
|
||||||
<text x="30" y="146">44 commits in the last 26 weeks · through 2026-09-14</text>
|
<text x="30" y="146">56 commits in the last 26 weeks · through 2026-09-15</text>
|
||||||
<text x="286" y="130" text-anchor="end">Less</text>
|
<text x="286" y="130" text-anchor="end">Less</text>
|
||||||
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
|
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
|
||||||
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>
|
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
|
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
|
||||||
<title>近 26 周共 44 次提交</title>
|
<title>近 26 周共 56 次提交</title>
|
||||||
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
|
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
|
||||||
<text x="58" y="12">4月</text>
|
<text x="58" y="12">4月</text>
|
||||||
<text x="114" y="12">5月</text>
|
<text x="114" y="12">5月</text>
|
||||||
@@ -188,8 +188,9 @@
|
|||||||
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>2026-09-12:0 次提交</title></rect>
|
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>2026-09-12:0 次提交</title></rect>
|
||||||
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>2026-09-13:42 次提交</title></rect>
|
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>2026-09-13:42 次提交</title></rect>
|
||||||
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2026-09-14:2 次提交</title></rect>
|
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2026-09-14:2 次提交</title></rect>
|
||||||
|
<rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>2026-09-15:12 次提交</title></rect>
|
||||||
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
|
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
|
||||||
<text x="30" y="146">近 26 周共 44 次提交 · 截至 2026-09-14</text>
|
<text x="30" y="146">近 26 周共 56 次提交 · 截至 2026-09-15</text>
|
||||||
<text x="286" y="130" text-anchor="end">少</text>
|
<text x="286" y="130" text-anchor="end">少</text>
|
||||||
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
|
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
|
||||||
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>
|
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -3,6 +3,15 @@
|
|||||||
记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。
|
记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。
|
||||||
官网、构建与发布脚本等不影响应用本身的改动不在此记录。
|
官网、构建与发布脚本等不影响应用本身的改动不在此记录。
|
||||||
|
|
||||||
|
## 未发布
|
||||||
|
|
||||||
|
### 2026-09-16
|
||||||
|
|
||||||
|
#### 修复
|
||||||
|
|
||||||
|
- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。
|
||||||
|
- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。
|
||||||
|
|
||||||
## 0.3.1 · 2026-09-15
|
## 0.3.1 · 2026-09-15
|
||||||
|
|
||||||
### 2026-09-15
|
### 2026-09-15
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
/// 强制走 IPv4 或 IPv6 发一个 HTTPS GET。
|
||||||
|
///
|
||||||
|
/// cleanip.io 只查请求方自己的出口,结果取决于这次连接用的是哪一族;URLSession 没法指定地址族,
|
||||||
|
/// 双栈网络下系统通常优先 IPv6,所以用 Network.framework 自己建连接、锁定 IP 版本,
|
||||||
|
/// 请求写成最简单的 HTTP/1.1(Connection: close,不要压缩),读到对方关闭为止。
|
||||||
|
/// 这一族没有路由时连接会进入 waiting,直接当失败处理,不干等超时。
|
||||||
|
enum AddressFamilyRequest {
|
||||||
|
/// 返回响应体与状态码;连接失败时状态码为 0
|
||||||
|
static func get(_ url: URL, ipv6: Bool, timeout: TimeInterval = 10) async -> (Data?, Int) {
|
||||||
|
guard url.scheme == "https", let host = url.host, !host.isEmpty else { return (nil, 0) }
|
||||||
|
var path = url.path.isEmpty ? "/" : url.path
|
||||||
|
if let query = url.query { path += "?" + query }
|
||||||
|
|
||||||
|
let tls = NWProtocolTLS.Options()
|
||||||
|
sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1")
|
||||||
|
let tcp = NWProtocolTCP.Options()
|
||||||
|
tcp.connectionTimeout = Int(timeout)
|
||||||
|
let parameters = NWParameters(tls: tls, tcp: tcp)
|
||||||
|
if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
|
||||||
|
ip.version = ipv6 ? .v6 : .v4
|
||||||
|
}
|
||||||
|
let connection = NWConnection(host: NWEndpoint.Host(host), port: .https, using: parameters)
|
||||||
|
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nUser-Agent: OpenStats\r\nAccept: application/json\r\nConnection: close\r\n\r\n"
|
||||||
|
guard let raw = await Exchange(connection: connection, request: Data(request.utf8), timeout: timeout).run() else {
|
||||||
|
return (nil, 0)
|
||||||
|
}
|
||||||
|
return parse(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拆出状态码与响应体,处理 chunked 与 Content-Length
|
||||||
|
static func parse(_ raw: Data) -> (Data?, Int) {
|
||||||
|
guard let separator = raw.range(of: Data("\r\n\r\n".utf8)),
|
||||||
|
let head = String(data: raw[raw.startIndex..<separator.lowerBound], encoding: .utf8) else { return (nil, 0) }
|
||||||
|
let lines = head.components(separatedBy: "\r\n")
|
||||||
|
let statusParts = lines.first?.split(separator: " ") ?? []
|
||||||
|
guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else { return (nil, 0) }
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
for line in lines.dropFirst() {
|
||||||
|
guard let colon = line.firstIndex(of: ":") else { continue }
|
||||||
|
headers[line[..<colon].lowercased()] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
var body = Data(raw[separator.upperBound...])
|
||||||
|
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||||
|
guard let decoded = dechunk(body) else { return (nil, status) }
|
||||||
|
body = decoded
|
||||||
|
} else if let length = headers["content-length"].flatMap({ Int($0) }) {
|
||||||
|
guard body.count >= length else { return (nil, status) }
|
||||||
|
body = body.prefix(length)
|
||||||
|
}
|
||||||
|
return (body, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解开 chunked 编码;数据不完整时返回 nil
|
||||||
|
static func dechunk(_ data: Data) -> Data? {
|
||||||
|
let bytes = [UInt8](data)
|
||||||
|
var output = Data()
|
||||||
|
var index = 0
|
||||||
|
while index < bytes.count {
|
||||||
|
guard let lineEnd = (index..<max(index, bytes.count - 1)).first(where: { bytes[$0] == 13 && bytes[$0 + 1] == 10 }),
|
||||||
|
let sizeText = String(bytes: bytes[index..<lineEnd], encoding: .ascii)?
|
||||||
|
.split(separator: ";").first?.trimmingCharacters(in: .whitespaces),
|
||||||
|
let size = Int(sizeText, radix: 16) else { return nil }
|
||||||
|
index = lineEnd + 2
|
||||||
|
if size == 0 { return output }
|
||||||
|
guard bytes.count - index >= size else { return nil }
|
||||||
|
output.append(contentsOf: bytes[index..<index + size])
|
||||||
|
index += size + 2
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次连接的收发。所有状态只在自己的串行队列上改,结果只回调一次
|
||||||
|
private final class Exchange: @unchecked Sendable {
|
||||||
|
private let connection: NWConnection
|
||||||
|
private let request: Data
|
||||||
|
private let timeout: TimeInterval
|
||||||
|
private let queue = DispatchQueue(label: "OpenStats.AddressFamilyRequest")
|
||||||
|
private var buffer = Data()
|
||||||
|
private var continuation: CheckedContinuation<Data?, Never>?
|
||||||
|
|
||||||
|
/// 响应超过 2 MB 就不要了,cleanip.io 的 JSON 通常几 KB
|
||||||
|
private static let limit = 2 << 20
|
||||||
|
|
||||||
|
init(connection: NWConnection, request: Data, timeout: TimeInterval) {
|
||||||
|
self.connection = connection
|
||||||
|
self.request = request
|
||||||
|
self.timeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async -> Data? {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
queue.async {
|
||||||
|
self.continuation = continuation
|
||||||
|
self.connection.stateUpdateHandler = { [self] state in
|
||||||
|
switch state {
|
||||||
|
case .ready: send()
|
||||||
|
case .waiting, .failed, .cancelled: finish(nil)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.connection.start(queue: self.queue)
|
||||||
|
self.queue.asyncAfter(deadline: .now() + self.timeout) { [self] in finish(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send() {
|
||||||
|
connection.send(content: request, completion: .contentProcessed { [self] error in
|
||||||
|
if error != nil { finish(nil) } else { receive() }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receive() {
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [self] data, _, isComplete, error in
|
||||||
|
if let data { buffer.append(data) }
|
||||||
|
if buffer.count > Self.limit { finish(nil); return }
|
||||||
|
// 对方关闭连接(或关闭时带出 TLS 错误)就是读完了,完整性交给 parse 判断
|
||||||
|
if isComplete || error != nil { finish(buffer.isEmpty ? nil : buffer); return }
|
||||||
|
receive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ result: Data?) {
|
||||||
|
guard let continuation else { return }
|
||||||
|
self.continuation = nil
|
||||||
|
connection.stateUpdateHandler = nil
|
||||||
|
connection.cancel()
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ public struct PublicAddresses: Sendable, Equatable, Codable {
|
|||||||
public var purity: Purity?
|
public var purity: Purity?
|
||||||
public var risk: Risk?
|
public var risk: Risk?
|
||||||
public var reportURL: URL?
|
public var reportURL: URL?
|
||||||
|
/// 查纯净度时 Cloudflare 给出的这一族地址。分流代理下 cleanip.io 看到的出口可能不同,
|
||||||
|
/// 界面显示 cleanip.io 看到的那个,缓存按这个比对“地址变没变”
|
||||||
|
public var queriedAddress: String?
|
||||||
|
|
||||||
public struct Purity: Sendable, Equatable, Codable {
|
public struct Purity: Sendable, Equatable, Codable {
|
||||||
public var score: Int
|
public var score: Int
|
||||||
@@ -144,13 +147,13 @@ public enum PublicAddressLookup {
|
|||||||
public var purity: PublicAddresses.Purity?
|
public var purity: PublicAddresses.Purity?
|
||||||
public var risk: PublicAddresses.Risk?
|
public var risk: PublicAddresses.Risk?
|
||||||
public var reportURL: URL?
|
public var reportURL: URL?
|
||||||
|
/// cleanip.io 看到的出口地址,也就是这份结果对应的地址
|
||||||
|
public var ip: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func cleanIPURL(for ip: String) -> URL? {
|
/// 只查请求方自己的出口 IP:cleanip.io 的 /cli 自 2026-09-15 起不再接受 `ip` 参数(带了一律 400)。
|
||||||
var components = URLComponents(string: "https://cleanip.io/cli")
|
/// cleanip.io 在 Cloudflare 后面,A 与 AAAA 都有,所以锁定地址族各连一次,就能分别查到本机的公网 IPv4 与 IPv6
|
||||||
components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")]
|
static let cleanIPURL = URL(string: "https://cleanip.io/cli?json=1")!
|
||||||
return components?.url
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CleanIPResponse: Decodable {
|
struct CleanIPResponse: Decodable {
|
||||||
struct Geo: Decodable {
|
struct Geo: Decodable {
|
||||||
@@ -244,27 +247,36 @@ public enum PublicAddressLookup {
|
|||||||
if risk.isAbuser == true { flags.append("abuser") }
|
if risk.isAbuser == true { flags.append("abuser") }
|
||||||
info.risk = PublicAddresses.Risk(score: min(100, max(0, score)), label: risk.riskLabel.flatMap(nonEmpty), flags: flags)
|
info.risk = PublicAddresses.Risk(score: min(100, max(0, score)), label: risk.riskLabel.flatMap(nonEmpty), flags: flags)
|
||||||
}
|
}
|
||||||
if let ip = response.ip, isAddress(ip) { info.reportURL = URL(string: "https://cleanip.io/\(ip)") }
|
if let ip = response.ip, isAddress(ip) {
|
||||||
|
info.ip = ip
|
||||||
|
info.reportURL = URL(string: "https://cleanip.io/\(ip)")
|
||||||
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 IP 缓存一小时;收到 429 后到当天(UTC)结束都不再请求
|
/// 按 IP 缓存一小时;收到 429 后到当天(UTC)结束都不再请求
|
||||||
actor GeoCache {
|
actor GeoCache {
|
||||||
private static let lifetime: TimeInterval = 60 * 60
|
private static let lifetime: TimeInterval = 60 * 60
|
||||||
private var entries: [String: (info: GeoInfo?, date: Date)] = [:]
|
private var entries: [String: (info: GeoInfo, date: Date)] = [:]
|
||||||
private var blockedUntil: Date?
|
private var blockedUntil: Date?
|
||||||
|
|
||||||
|
/// `ip` 是 Cloudflare 给出的地址,决定锁定哪一族、按它缓存;查不到的不缓存,下次打开详情再试
|
||||||
func lookup(_ ip: String) async -> GeoInfo? {
|
func lookup(_ ip: String) async -> GeoInfo? {
|
||||||
if let entry = entries[ip], Date().timeIntervalSince(entry.date) < Self.lifetime { return entry.info }
|
if let entry = entries[ip], Date().timeIntervalSince(entry.date) < Self.lifetime { return entry.info }
|
||||||
if let blockedUntil, Date() < blockedUntil { return nil }
|
if let blockedUntil, Date() < blockedUntil { return nil }
|
||||||
guard let url = PublicAddressLookup.cleanIPURL(for: ip) else { return nil }
|
let ipv6 = ip.contains(":")
|
||||||
let (data, status) = await PublicAddressLookup.getWithStatus(url)
|
var (data, status) = await AddressFamilyRequest.get(PublicAddressLookup.cleanIPURL, ipv6: ipv6)
|
||||||
|
// 锁定地址族的连接建不起来(比如只认系统 HTTP 代理的网络)时,退回系统默认的请求方式
|
||||||
|
if status == 0 {
|
||||||
|
(data, status) = await PublicAddressLookup.getWithStatus(PublicAddressLookup.cleanIPURL)
|
||||||
|
}
|
||||||
if status == 429 {
|
if status == 429 {
|
||||||
blockedUntil = Self.nextUTCMidnight()
|
blockedUntil = Self.nextUTCMidnight()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard status == 200, let data else { return nil }
|
// 退回的请求走哪一族由系统决定,查到的不是要查的那一族就不能拿来用
|
||||||
let info = PublicAddressLookup.parseCleanIP(data)
|
guard status == 200, let data, let info = PublicAddressLookup.parseCleanIP(data),
|
||||||
|
let seen = info.ip, seen.contains(":") == ipv6 else { return nil }
|
||||||
entries[ip] = (info, Date())
|
entries[ip] = (info, Date())
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,8 +215,9 @@ public final class NetworkController {
|
|||||||
|
|
||||||
// MARK: 公网 IP
|
// MARK: 公网 IP
|
||||||
|
|
||||||
/// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各向 cleanip.io 查一次:地址没变、结果不满 7 天
|
/// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各向 cleanip.io 查一次(锁定 IPv4 / IPv6 连接,
|
||||||
/// 的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查
|
/// 因为它只查请求方自己):地址没变、结果不满 7 天的那一族沿用缓存,否则重新查并写回缓存。
|
||||||
|
/// `force` 为真时(用户点了刷新)两族都重查
|
||||||
func lookUpPublicAddresses(force: Bool = false) {
|
func lookUpPublicAddresses(force: Bool = false) {
|
||||||
guard settings.publicIPLookup, !isLookingUpPublic else { return }
|
guard settings.publicIPLookup, !isLookingUpPublic else { return }
|
||||||
// 有缓存可显示时不转圈,后台悄悄核对
|
// 有缓存可显示时不转圈,后台悄悄核对
|
||||||
@@ -229,23 +230,24 @@ public final class NetworkController {
|
|||||||
|
|
||||||
@MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? {
|
@MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? {
|
||||||
guard let ip else { return nil }
|
guard let ip else { return nil }
|
||||||
if !force, let cached,
|
let previous = cached?.results[family.rawValue]
|
||||||
let old = cached.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip,
|
let sameAddress = previous.map { ($0.queriedAddress ?? (family == .v4 ? $0.ipv4 : $0.ipv6)) == ip } ?? false
|
||||||
let date = cached.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime {
|
// 上次什么都没查到的不算缓存,这次重查
|
||||||
var kept = old
|
if !force, sameAddress, let previous, previous.hasLookupData,
|
||||||
kept.ipv4 = base.ipv4
|
let date = cached?.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime {
|
||||||
kept.ipv6 = base.ipv6
|
return previous
|
||||||
return kept
|
|
||||||
}
|
}
|
||||||
var result = base
|
var result = base
|
||||||
|
result.queriedAddress = ip
|
||||||
if let info = await PublicAddressLookup.geo(for: ip) {
|
if let info = await PublicAddressLookup.geo(for: ip) {
|
||||||
result.apply(info)
|
result.apply(info)
|
||||||
|
// 分流代理下 cleanip.io 看到的出口可能与 Cloudflare 不同:纯净度是那个地址的,界面也显示那个地址
|
||||||
|
if let seen = info.ip, seen != ip {
|
||||||
|
if family == .v4 { result.ipv4 = seen } else { result.ipv6 = seen }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard result.asn != nil || result.city != nil || result.purity != nil else {
|
// 这次没查到,地址又没变,先继续用旧的
|
||||||
// 这次没查到,地址又没变,先继续用旧的
|
if !result.hasLookupData, sameAddress, let previous, previous.hasLookupData { return previous }
|
||||||
if let old = cached?.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip { return old }
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +256,22 @@ public final class NetworkController {
|
|||||||
var results: [IPFamily: PublicAddresses] = [:]
|
var results: [IPFamily: PublicAddresses] = [:]
|
||||||
if let v4 = await v4 { results[.v4] = v4 }
|
if let v4 = await v4 { results[.v4] = v4 }
|
||||||
if let v6 = await v6 { results[.v6] = v6 }
|
if let v6 = await v6 { results[.v6] = v6 }
|
||||||
|
// 两份结果都带着两族地址,各族以自己那份结果为准;缓存里留下的另一族旧地址不算
|
||||||
|
let ipv4 = results[.v4]?.ipv4 ?? base.ipv4
|
||||||
|
let ipv6 = results[.v6]?.ipv6 ?? base.ipv6
|
||||||
|
for family in Array(results.keys) {
|
||||||
|
results[family]?.ipv4 = ipv4
|
||||||
|
results[family]?.ipv6 = ipv6
|
||||||
|
}
|
||||||
|
|
||||||
var dates = cached?.geoDates ?? [:]
|
// 只留这次还有结果的那几族的时间
|
||||||
|
var dates = (cached?.geoDates ?? [:]).filter { key, _ in IPFamily(rawValue: key).map { results[$0] != nil } ?? false }
|
||||||
for (family, result) in results {
|
for (family, result) in results {
|
||||||
|
// 没查到的不记时间,下次打开详情就会重查,而不是空着等 7 天
|
||||||
|
guard result.hasLookupData else {
|
||||||
|
dates[family.rawValue] = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
let previous = cached?.results[family.rawValue]
|
let previous = cached?.results[family.rawValue]
|
||||||
let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city
|
let sameAsCached = previous?.asn == result.asn && previous?.purity == result.purity && previous?.city == result.city
|
||||||
if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now }
|
if force || !sameAsCached || dates[family.rawValue] == nil { dates[family.rawValue] = now }
|
||||||
@@ -362,6 +377,11 @@ extension NetworkController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PublicAddresses {
|
||||||
|
/// cleanip.io 这次查到了东西(归属地、ASN 或纯净度之一)
|
||||||
|
var hasLookupData: Bool { asn != nil || city != nil || purity != nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// 公网地址的地址族
|
/// 公网地址的地址族
|
||||||
public enum IPFamily: String, CaseIterable, Identifiable, Sendable, Codable {
|
public enum IPFamily: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||||
case v4, v6
|
case v4, v6
|
||||||
|
|||||||
@@ -188,6 +188,23 @@ struct LiveSamplerTests {
|
|||||||
#expect(PublicAddressLookup.parseCleanIP(Data(#"{"ok":false,"error":"bad"}"#.utf8)) == nil)
|
#expect(PublicAddressLookup.parseCleanIP(Data(#"{"ok":false,"error":"bad"}"#.utf8)) == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func parsesHTTPResponses() throws {
|
||||||
|
let plain = Data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"ok\":true}".utf8)
|
||||||
|
let (body, status) = AddressFamilyRequest.parse(plain)
|
||||||
|
#expect(status == 200 && body == Data("{\"ok\":true}".utf8))
|
||||||
|
|
||||||
|
let chunked = Data("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;x=1\r\n world\r\n0\r\n\r\n".utf8)
|
||||||
|
#expect(AddressFamilyRequest.parse(chunked).0 == Data("hello world".utf8))
|
||||||
|
|
||||||
|
let limited = Data("HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\n\r\n".utf8)
|
||||||
|
#expect(AddressFamilyRequest.parse(limited).1 == 429)
|
||||||
|
|
||||||
|
// 读到一半断开:chunked 缺结尾、Content-Length 不够,都不能当成完整结果
|
||||||
|
#expect(AddressFamilyRequest.parse(Data("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel".utf8)).0 == nil)
|
||||||
|
#expect(AddressFamilyRequest.parse(Data("HTTP/1.1 200 OK\r\nContent-Length: 50\r\n\r\n{}".utf8)).0 == nil)
|
||||||
|
#expect(AddressFamilyRequest.parse(Data("garbage".utf8)).1 == 0)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func rejectsUnsafeCountryCode() {
|
@Test func rejectsUnsafeCountryCode() {
|
||||||
#expect(PublicAddressLookup.validCountryCode("cn") == "CN")
|
#expect(PublicAddressLookup.validCountryCode("cn") == "CN")
|
||||||
#expect(PublicAddressLookup.validCountryCode("../x") == nil)
|
#expect(PublicAddressLookup.validCountryCode("../x") == nil)
|
||||||
|
|||||||
@@ -59,9 +59,19 @@ system language by default; switch it in Settings → General → Language.
|
|||||||
<!-- changelog:start -->
|
<!-- changelog:start -->
|
||||||
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
|
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
|
||||||
|
|
||||||
Latest release **0.3.1** (2026-09-15) · [full changelog](CHANGELOG.md) (kept in Chinese)
|
Latest release **0.3.1** (2026-09-15) · **2** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
|
<summary><b>2026-09-16</b> · Unreleased · 2 fixed</summary>
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。
|
||||||
|
- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
<summary><b>2026-09-15</b> · 0.3.1 · 2 added</summary>
|
<summary><b>2026-09-15</b> · 0.3.1 · 2 added</summary>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
@@ -114,21 +124,6 @@ Latest release **0.3.1** (2026-09-15) · [full changelog](CHANGELOG.md) (kept in
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>2026-09-14</b> · 0.3.0 · 2 added · 2 changed</summary>
|
|
||||||
|
|
||||||
**Added**
|
|
||||||
|
|
||||||
- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。
|
|
||||||
- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。
|
|
||||||
|
|
||||||
**Changed**
|
|
||||||
|
|
||||||
- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。
|
|
||||||
- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- changelog:end -->
|
<!-- changelog:end -->
|
||||||
|
|
||||||
## Activity
|
## Activity
|
||||||
|
|||||||
+11
-16
@@ -55,9 +55,19 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
|
|||||||
<!-- changelog:start -->
|
<!-- changelog:start -->
|
||||||
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
||||||
|
|
||||||
最新版本 **0.3.1**(2026-09-15) · [完整更新日志](CHANGELOG.md)
|
最新版本 **0.3.1**(2026-09-15) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
|
<summary><b>2026-09-16</b> · 未发布 · 修复 2</summary>
|
||||||
|
|
||||||
|
**修复**
|
||||||
|
|
||||||
|
- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。
|
||||||
|
- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
<summary><b>2026-09-15</b> · 0.3.1 · 新增 2</summary>
|
<summary><b>2026-09-15</b> · 0.3.1 · 新增 2</summary>
|
||||||
|
|
||||||
**新增**
|
**新增**
|
||||||
@@ -110,21 +120,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>2026-09-14</b> · 0.3.0 · 新增 2 · 调整 2</summary>
|
|
||||||
|
|
||||||
**新增**
|
|
||||||
|
|
||||||
- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。
|
|
||||||
- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。
|
|
||||||
|
|
||||||
**调整**
|
|
||||||
|
|
||||||
- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。
|
|
||||||
- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- changelog:end -->
|
<!-- changelog:end -->
|
||||||
|
|
||||||
## 活跃度
|
## 活跃度
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ settings:
|
|||||||
SWIFT_VERSION: "6.0"
|
SWIFT_VERSION: "6.0"
|
||||||
MARKETING_VERSION: "0.3.1"
|
MARKETING_VERSION: "0.3.1"
|
||||||
# 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改
|
# 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改
|
||||||
CURRENT_PROJECT_VERSION: "0051"
|
CURRENT_PROJECT_VERSION: "0053"
|
||||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
# 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md)
|
# 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md)
|
||||||
CODE_SIGN_STYLE: Manual
|
CODE_SIGN_STYLE: Manual
|
||||||
|
|||||||
Reference in new issue
Block a user