From d305e5e3e1b66dcf60c2c615368aba5b3993075e Mon Sep 17 00:00:00 2001 From: gentpan Date: Wed, 16 Sep 2026 01:47:21 +0500 Subject: [PATCH] =?UTF-8?q?=E7=BA=AF=E5=87=80=E5=BA=A6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=8F=AA=E6=9F=A5=E8=87=AA=E5=B7=B1=EF=BC=9A=E9=94=81=E5=AE=9A?= =?UTF-8?q?=20IPv4=20/=20IPv6=20=E8=BF=9E=E6=8E=A5=E5=88=86=E5=88=AB?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=20cleanip.io/cli=3Fjson=3D1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 返回对应地址的纯净度 --- ARCHITECTURE.md | 11 +- Assets/readme/activity.svg | 5 +- Assets/readme/activity.zh.svg | 5 +- CHANGELOG.md | 9 ++ .../Metrics/AddressFamilyRequest.swift | 134 ++++++++++++++++++ .../Sources/Metrics/PublicAddressLookup.swift | 34 +++-- .../OpenStatsUI/State/NetworkController.swift | 50 +++++-- .../Tests/MetricsTests/MetricsTests.swift | 17 +++ README.md | 27 ++-- README.zh-CN.md | 27 ++-- project.yml | 2 +- 11 files changed, 255 insertions(+), 66 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fab1767..74704c1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,9 +158,14 @@ is open and back to accessory when all are closed. - **Interface and addresses** — `SCDynamicStore` / `SCPreferences` for the primary and physical service, `getifaddrs` for addresses, CoreWLAN for signal and rate. - **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 - Mac by `PublicAddressLookup` (one request per address family; results are cached per IP for an hour - in memory and for a week on disk, and a 429 stops further calls until the next UTC day). Country + type, native / broadcast and the cleanliness score come from `cleanip.io/cli?json=1`, queried directly + from each Mac by `PublicAddressLookup`. The endpoint only reports the caller's own address, so + `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 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 diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index 331c338..cc0d4a4 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,5 +1,5 @@ -44 commits in the last 26 weeks +56 commits in the last 26 weeks Apr May @@ -188,8 +188,9 @@ 0 commits on 2026-09-12 42 commits on 2026-09-13 2 commits on 2026-09-14 +12 commits on 2026-09-15 -44 commits in the last 26 weeks · through 2026-09-14 +56 commits in the last 26 weeks · through 2026-09-15 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index a5cb7b7..35ca9c2 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,5 +1,5 @@ -近 26 周共 44 次提交 +近 26 周共 56 次提交 4月 5月 @@ -188,8 +188,9 @@ 2026-09-12:0 次提交 2026-09-13:42 次提交 2026-09-14:2 次提交 +2026-09-15:12 次提交 -近 26 周共 44 次提交 · 截至 2026-09-14 +近 26 周共 56 次提交 · 截至 2026-09-15 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index 579f94c..522cfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-09-16 + +#### 修复 + +- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。 +- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。 + ## 0.3.1 · 2026-09-15 ### 2026-09-15 diff --git a/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift b/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift new file mode 100644 index 0000000..cd2c425 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift @@ -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..= 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[..= 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..= size else { return nil } + output.append(contentsOf: bytes[index..? + + /// 响应超过 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) + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift index 1b050ed..f6d4a81 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -31,6 +31,9 @@ public struct PublicAddresses: Sendable, Equatable, Codable { public var purity: Purity? public var risk: Risk? public var reportURL: URL? + /// 查纯净度时 Cloudflare 给出的这一族地址。分流代理下 cleanip.io 看到的出口可能不同, + /// 界面显示 cleanip.io 看到的那个,缓存按这个比对“地址变没变” + public var queriedAddress: String? public struct Purity: Sendable, Equatable, Codable { public var score: Int @@ -144,13 +147,13 @@ public enum PublicAddressLookup { public var purity: PublicAddresses.Purity? public var risk: PublicAddresses.Risk? public var reportURL: URL? + /// cleanip.io 看到的出口地址,也就是这份结果对应的地址 + public var ip: String? } - static func cleanIPURL(for ip: String) -> URL? { - var components = URLComponents(string: "https://cleanip.io/cli") - components?.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "json", value: "1")] - return components?.url - } + /// 只查请求方自己的出口 IP:cleanip.io 的 /cli 自 2026-09-15 起不再接受 `ip` 参数(带了一律 400)。 + /// cleanip.io 在 Cloudflare 后面,A 与 AAAA 都有,所以锁定地址族各连一次,就能分别查到本机的公网 IPv4 与 IPv6 + static let cleanIPURL = URL(string: "https://cleanip.io/cli?json=1")! struct CleanIPResponse: Decodable { struct Geo: Decodable { @@ -244,27 +247,36 @@ public enum PublicAddressLookup { if risk.isAbuser == true { flags.append("abuser") } 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 } /// 按 IP 缓存一小时;收到 429 后到当天(UTC)结束都不再请求 actor GeoCache { 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? + /// `ip` 是 Cloudflare 给出的地址,决定锁定哪一族、按它缓存;查不到的不缓存,下次打开详情再试 func lookup(_ ip: String) async -> GeoInfo? { if let entry = entries[ip], Date().timeIntervalSince(entry.date) < Self.lifetime { return entry.info } if let blockedUntil, Date() < blockedUntil { return nil } - guard let url = PublicAddressLookup.cleanIPURL(for: ip) else { return nil } - let (data, status) = await PublicAddressLookup.getWithStatus(url) + let ipv6 = ip.contains(":") + var (data, status) = await AddressFamilyRequest.get(PublicAddressLookup.cleanIPURL, ipv6: ipv6) + // 锁定地址族的连接建不起来(比如只认系统 HTTP 代理的网络)时,退回系统默认的请求方式 + if status == 0 { + (data, status) = await PublicAddressLookup.getWithStatus(PublicAddressLookup.cleanIPURL) + } if status == 429 { blockedUntil = Self.nextUTCMidnight() 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()) return info } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift index f7d571c..ae141f7 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/NetworkController.swift @@ -215,8 +215,9 @@ public final class NetworkController { // MARK: 公网 IP - /// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各向 cleanip.io 查一次:地址没变、结果不满 7 天 - /// 的那一族沿用缓存,否则重新查并写回缓存。`force` 为真时(用户点了刷新)两族都重查 + /// 先只取公网地址(Cloudflare,便宜且不限额),再按地址族各向 cleanip.io 查一次(锁定 IPv4 / IPv6 连接, + /// 因为它只查请求方自己):地址没变、结果不满 7 天的那一族沿用缓存,否则重新查并写回缓存。 + /// `force` 为真时(用户点了刷新)两族都重查 func lookUpPublicAddresses(force: Bool = false) { guard settings.publicIPLookup, !isLookingUpPublic else { return } // 有缓存可显示时不转圈,后台悄悄核对 @@ -229,23 +230,24 @@ public final class NetworkController { @MainActor func resolve(_ family: IPFamily, ip: String?) async -> PublicAddresses? { guard let ip else { return nil } - if !force, let cached, - let old = cached.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip, - let date = cached.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime { - var kept = old - kept.ipv4 = base.ipv4 - kept.ipv6 = base.ipv6 - return kept + let previous = cached?.results[family.rawValue] + let sameAddress = previous.map { ($0.queriedAddress ?? (family == .v4 ? $0.ipv4 : $0.ipv6)) == ip } ?? false + // 上次什么都没查到的不算缓存,这次重查 + if !force, sameAddress, let previous, previous.hasLookupData, + let date = cached?.geoDates[family.rawValue], now.timeIntervalSince(date) < Self.geoCacheLifetime { + return previous } var result = base + result.queriedAddress = ip if let info = await PublicAddressLookup.geo(for: ip) { 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 let old = cached?.results[family.rawValue], (family == .v4 ? old.ipv4 : old.ipv6) == ip { return old } - return result - } + // 这次没查到,地址又没变,先继续用旧的 + if !result.hasLookupData, sameAddress, let previous, previous.hasLookupData { return previous } return result } @@ -254,9 +256,22 @@ public final class NetworkController { var results: [IPFamily: PublicAddresses] = [:] if let v4 = await v4 { results[.v4] = v4 } 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 { + // 没查到的不记时间,下次打开详情就会重查,而不是空着等 7 天 + guard result.hasLookupData else { + dates[family.rawValue] = nil + continue + } let previous = cached?.results[family.rawValue] 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 } @@ -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 { case v4, v6 diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index d41e75f..39dbf56 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -188,6 +188,23 @@ struct LiveSamplerTests { #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() { #expect(PublicAddressLookup.validCountryCode("cn") == "CN") #expect(PublicAddressLookup.validCountryCode("../x") == nil) diff --git a/README.md b/README.md index f2bf630..7d251ef 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,19 @@ system language by default; switch it in Settings → General → Language. -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)
+
2026-09-16 · Unreleased · 2 fixed + +**Fixed** + +- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。 +- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。 + + + +
2026-09-15 · 0.3.1 · 2 added **Added** @@ -114,21 +124,6 @@ Latest release **0.3.1** (2026-09-15) · [full changelog](CHANGELOG.md) (kept in
-
-2026-09-14 · 0.3.0 · 2 added · 2 changed - -**Added** - -- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。 -- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。 - -**Changed** - -- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。 -- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。 - -
- ## Activity diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ba2675..5b1c489 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,9 +55,19 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.3.1**(2026-09-15) · [完整更新日志](CHANGELOG.md) +最新版本 **0.3.1**(2026-09-15) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
+2026-09-16 · 未发布 · 修复 2 + +**修复** + +- 网络详情的 IP 纯净度一直显示“正在查询”、IP 地址区块只剩国家:cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。 +- 查询失败时的空结果不再被当成有效结果保存 7 天:没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。 + +
+ +
2026-09-15 · 0.3.1 · 新增 2 **新增** @@ -110,21 +120,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
-
-2026-09-14 · 0.3.0 · 新增 2 · 调整 2 - -**新增** - -- 系统通知新增“CPU 持续高负载”:总占用持续 1 分钟高于设定值(70% / 80% / 90%)时提醒,点通知打开 CPU 详情。 -- 菜单栏风格新增“折线历史”:最近 30 次采样(约 1 分钟)的走势折线,线下填淡色;设置里有示例预览。 - -**调整** - -- CPU 详情重新整理:顶部直接显示 CPU 温度(下方附离 100°C 的余量,不再把余量当主数值),走势线下标出最近 60 秒的峰值;核心热力图右侧加一根粗条画此刻各核心的占用;核心分工标出芯片型号,频率跟在占用后面用次要颜色显示;按应用汇总注明“以单核满载为 100%”,解释各应用相加为什么会超过顶部的总占用。 -- 主窗口的 CPU 页:走势线加高并带 25% / 50% / 75% 参考线,核心分工与排队程度并排显示,不再整页单列拉长。 - -
- ## 活跃度 diff --git a/project.yml b/project.yml index e248db4..a7b28b0 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: SWIFT_VERSION: "6.0" MARKETING_VERSION: "0.3.1" # 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改 - CURRENT_PROJECT_VERSION: "0051" + CURRENT_PROJECT_VERSION: "0053" MACOSX_DEPLOYMENT_TARGET: "14.0" # 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md) CODE_SIGN_STYLE: Manual