纯净度改为只查自己:锁定 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:
gentpan committed 2026-09-16 01:47:21 +05:00
1 parent 504b6eaeab
commit d305e5e3e1
11 files changed
+255 -66

No files matched your search

+8 -3
View File
@@ -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
+3 -2
View File
@@ -1,5 +1,5 @@
<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">
<text x="58" y="12">Apr</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="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="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">
<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>
<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"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+3 -2
View File
@@ -1,5 +1,5 @@
<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">
<text x="58" y="12">4月</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="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="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">
<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>
<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"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+9
View File
@@ -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
@@ -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 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
}
@@ -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
@@ -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)
+11 -16
View File
@@ -59,9 +59,19 @@ system language by default; switch it in Settings → General → Language.
<!-- changelog:start -->
<!-- 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>
<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>
**Added**
@@ -114,21 +124,6 @@ Latest release **0.3.1** (2026-09-15) · [full changelog](CHANGELOG.md) (kept in
</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 -->
## Activity
+11 -16
View File
@@ -55,9 +55,19 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
<!-- changelog:start -->
<!-- 由 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>
<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>
**新增**
@@ -110,21 +120,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
</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 -->
## 活跃度
+1 -1
View File
@@ -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