From b98c670c160512385284fc953f6727ddd747f4d9 Mon Sep 17 00:00:00 2001 From: gentpan Date: Thu, 24 Sep 2026 19:05:39 +0500 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E7=BB=9C=E6=B5=8B=E9=80=9F=EF=BC=9A?= =?UTF-8?q?=E5=BC=80=E7=9D=80=20VPN=20=E6=97=B6=E5=8F=AF=E9=80=89=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=E6=88=96=E7=BB=8F=E4=BB=A3=E7=90=86=EF=BC=8C=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=9C=AC=E6=9C=BA=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=8F=90=E5=89=8D=E5=BA=94=E7=AD=94=EF=BC=9B=E4=B8=89?= =?UTF-8?q?=E7=BD=91=E6=8D=A2=E6=88=90=E8=BF=90=E8=90=A5=E5=95=86=E6=A0=87?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 + .../Sources/Localization/Translations.swift | 3 + .../Sources/Metrics/EgressProbe.swift | 21 +- .../Sources/Metrics/SpeedTestNodes.swift | 17 - .../Sources/Metrics/SpeedTestProbe.swift | 426 ++++++++---------- .../Sources/Metrics/SpeedTestTransport.swift | 332 ++++++++++++++ .../AppKit/SpeedTestWindowController.swift | 1 + .../Panel/SpeedTestWindowView.swift | 101 +++-- .../Resources/Logos/carrier-mobile.svg | 1 + .../Resources/Logos/carrier-telecom.svg | 1 + .../Resources/Logos/carrier-unicom.svg | 1 + .../OpenStatsUI/State/AppSettings.swift | 6 + .../State/SpeedTestController.swift | 184 +++++--- .../Tests/MetricsTests/SpeedTestTests.swift | 29 ++ ThirdPartyNotices.md | 5 + 15 files changed, 797 insertions(+), 345 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/SpeedTestTransport.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-mobile.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-telecom.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-unicom.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e84fc..06bb339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ ## 未发布 +### 2026-09-24 + +#### 新增 + +- 网络测速可以选线路:开着 Surge、Clash 等 VPN 或代理时,窗口顶部出现“直连 / 经 Surge”切换。直连绑定物理网卡、绕开代理,测的是本机宽带;经代理测的是代理线路。下载、上传与延迟都按选的线路走,每张卡片标出这次走的是哪条。 +- 国内分省三网延迟改用电信、联通、移动的标志,不再用“电 / 联 / 移”字块。 + +#### 修复 + +- 开着 VPN 时网络测速的延迟全是假的(零点几到十几毫秒):TCP 建连被本机代理当场应答,测到的只是本机到代理这一段,与节点远近无关。现在直连时绕开代理计时;经代理时改用同一条连接上的 HTTP 往返,每次都要等目标真的回应。国内三网节点只允许测建连,始终直连测,VPN 不让绕开时直接提示,不再给出假数字。远距离线路偶尔丢握手包,国内节点多测一次、超时放宽到 4 秒。 +- 本机宽带的延迟扣掉 Cloudflare 响应头里报的服务器处理时间,与 Cloudflare 官方测速的算法一致。 +- 测速后顶栏出现“本次用掉”时,标题“网络测速”被挤成竖排。 +- 绕开代理解析域名时,Cloudflare DoH 连不上就换 Google、阿里的 DoH,出口与分流窗口的原始出口归属地也因此更容易查到。 + ### 2026-09-21 #### 新增 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index c8db8ab..cd96f64 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -85,6 +85,7 @@ SSD 健康 SSD Health Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。 Between backups Time Machine keeps snapshots on this Mac. Their space counts as purgeable and macOS removes them when it runs low. Deleting them by hand does not affect completed backups. VPN / 代理 VPN / Proxy VPN / 隧道 VPN / Tunnel +VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测 The VPN doesn't allow bypassing it, and its local proxy answers TCP handshakes itself, so real latency can't be measured. Turn off the VPN and try again VPN 不允许绕开它连接 The VPN doesn't allow bypassing it VPN 不允许绕开它连接,读不到本机网络原本的出口。下方按出口列出各网站。 The VPN doesn't allow connections that bypass it, so your network's original exit can't be read. Sites are listed by exit below. VPN 隧道 VPN Tunnel @@ -406,6 +407,7 @@ npm 缓存 npm Cache 国内网站直连,国际网站经 {} 的{}。 Chinese sites connect directly; international sites go through {}'s {}. 国内网站走了代理,访问会绕路变慢。 Chinese sites are going through the proxy, which takes a longer route and is slower. 国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link +国内节点只能测 TCP 建连,经代理测不准,所以始终直连测 China nodes only allow TCP connect timing, which a proxy distorts, so they're always tested directly 图形 Graphics 图形处理器 GPU 图形核心 GPU cores @@ -737,6 +739,7 @@ npm 缓存 npm Cache 检查更新失败 Update check failed 检查未能完成(退出码 {}) The check didn't complete (exit code {}) 检测中 {}/{} Checking {}/{} +检测到 {}。直连会绕开它,测的是本机宽带;经 {} 测的是代理线路 {} detected. Direct bypasses it to test your own connection; via {} tests the proxy route 检测到 {},但测试的网站都在直连,没有经过代理出口。 {} was detected, but all tested sites connect directly without going through a proxy exit. 模式 Mode 模拟器 Simulators diff --git a/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift b/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift index 37574cf..2017551 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift @@ -286,14 +286,21 @@ public enum EgressProber { return (ip, EgressGeo(info)) } - /// 经指定网卡用 Cloudflare DoH(JSON 格式)解析域名 + /// 经指定网卡用 DoH(JSON 格式)解析域名。某些线路上连不上 Cloudflare,依次换 Google、阿里 static func resolve(_ name: String, ipv6: Bool, interface: NWInterface) async -> String? { - let server = ipv6 ? "[2606:4700:4700::1111]" : "1.1.1.1" - guard let url = URL(string: "https://\(server)/dns-query?name=\(name)&type=\(ipv6 ? "AAAA" : "A")"), - let body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, - options: .init(interface: interface, bypassProxies: true, - accept: "application/dns-json"))) else { return nil } - return parseDoH(body, ipv6: ipv6) + let servers = ipv6 + ? ["[2606:4700:4700::1111]/dns-query", "[2001:4860:4860::8888]/resolve", "[2400:3200::1]/resolve"] + : ["1.1.1.1/dns-query", "8.8.8.8/resolve", "223.5.5.5/resolve"] + for server in servers { + guard let url = URL(string: "https://\(server)?name=\(name)&type=\(ipv6 ? 28 : 1)") else { continue } + if let body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, + options: .init(interface: interface, bypassProxies: true, + accept: "application/dns-json"))), + let address = parseDoH(body, ipv6: ipv6) { + return address + } + } + return nil } static func parseDoH(_ body: Data, ipv6: Bool) -> String? { diff --git a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift index fea5897..22de874 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift @@ -21,23 +21,6 @@ public enum ChinaCarrier: String, Sendable, CaseIterable, Codable { case .mobile: "Mobile" } } - - /// 标记里的字:三家的正式标志是注册商标,不随应用打包,用品牌色加一个字代替 - public var mark: String { - switch self { - case .telecom: "电" - case .unicom: "联" - case .mobile: "移" - } - } - - public var markEnglish: String { - switch self { - case .telecom: "CT" - case .unicom: "CU" - case .mobile: "CM" - } - } } /// 国内测速节点:zstaticcdn.com 的省级 TCPPing 节点,每省电信、联通、移动各一个。 diff --git a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift index 78358f6..4ec377a 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift @@ -20,14 +20,21 @@ public struct LatencyResult: Sendable, Codable, Equatable { } /// 用 TCP 建连的耗时代替 ping:目标节点大多不回 ICMP,握手时间同样能反映往返延迟。 -/// 只建连、不发数据,连上立即断开 +/// 只建连、不发数据,连上立即断开。 +/// +/// 只在不经代理时准确:经 Surge、Clash 等代理时握手由本机代理当场应答,要改用 `HTTPLatencyProbe` public enum TCPLatencyProbe { - public static func measure(host: String, port: UInt16, attempts: Int = 3, + public static func measure(host: String, port: UInt16, path: SpeedPath = .system, attempts: Int = 3, timeout: TimeInterval = 3) async -> LatencyResult { var result = LatencyResult(attempts: attempts) + // 需要自己解析的先解析好,DNS 查询不计入握手时间 + guard let address = await path.address(for: host) else { + result.failures = attempts + return result + } for _ in 0.. Double? { + private static func connect(host: String, port: UInt16, path: SpeedPath, timeout: TimeInterval) async -> Double? { guard let port = NWEndpoint.Port(rawValue: port) else { return nil } let options = NWProtocolTCP.Options() options.connectionTimeout = Int(timeout.rounded(.up)) options.noDelay = true - let connection = NWConnection(host: NWEndpoint.Host(host), port: port, - using: NWParameters(tls: nil, tcp: options)) + let parameters = NWParameters(tls: nil, tcp: options) + path.apply(to: parameters) + let connection = NWConnection(host: NWEndpoint.Host(host), port: port, using: parameters) let clock = ContinuousClock() let start = clock.now - let once = ResumeOnce() + let gate = ContinuationGate() let milliseconds: Double? = await withTaskCancellationHandler { await withCheckedContinuation { continuation in - once.attach(continuation) + gate.attach(continuation) connection.stateUpdateHandler = { state in switch state { case .ready: - once.resume(Self.elapsed(from: start, clock: clock)) + gate.resume(SpeedConnection.milliseconds(clock.now - start)) case .failed, .cancelled: - once.resume(nil) + gate.resume(nil) case .waiting: // 路由不可达、端口被拒:不再等超时,直接算这次失败 - once.resume(nil) + gate.resume(nil) default: break } } connection.start(queue: queue) // connectionTimeout 在个别情况下不触发状态变化,兜一个自己的超时 - queue.asyncAfter(deadline: .now() + timeout) { once.resume(nil) } + queue.asyncAfter(deadline: .now() + timeout) { gate.resume(nil) } } } onCancel: { - once.resume(nil) + gate.resume(nil) } connection.cancel() return milliseconds } - - private static func elapsed(from start: ContinuousClock.Instant, clock: ContinuousClock) -> Double { - let duration = clock.now - start - return Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15 - } } -/// 状态回调、超时与取消都可能同时到达,续体只能恢复一次 -private final class ResumeOnce: @unchecked Sendable { - private let lock = NSLock() - private var continuation: CheckedContinuation? - private var done = false - private var pending: Double?? - - func attach(_ continuation: CheckedContinuation) { - lock.lock() - if let pending { - lock.unlock() - continuation.resume(returning: pending) - return +/// 经代理时的延迟:同一条连接上先发一次请求预热(代理这时才真正去连目标),再接连计时几次取最快。 +/// 每次都要等目标的响应回来,测到的是经代理到目标的真实往返,包括代理服务器那一段 +public enum HTTPLatencyProbe { + public static func measure(_ url: URL, path: SpeedPath, method: String = "HEAD", samples: Int = 3, + timeout: TimeInterval = 5) async -> LatencyResult { + var result = LatencyResult(attempts: samples, failures: samples) + let slot = ConnectionSlot() + // 建连与全部请求共用一个期限,目标一直不回就掐断 + let watchdog = Task { + try? await Task.sleep(for: .seconds(timeout * 3)) + slot.cancel() } - self.continuation = continuation - lock.unlock() - } - - func resume(_ value: Double?) { - lock.lock() - guard !done else { return lock.unlock() } - done = true - guard let continuation else { - // 续体还没挂上(取消可能先到),先记下结果 - pending = .some(value) - lock.unlock() - return + defer { watchdog.cancel() } + let times: [Double] = await withTaskCancellationHandler { + var times: [Double] = [] + // 服务器偶尔回个错误就关掉连接,换一条连接重新预热,把样本补齐 + for _ in 0..<2 where times.count < samples && !slot.isCancelled { + guard let connection = await SpeedConnection.open(url, path: path, timeout: timeout) else { continue } + slot.hold(connection) + times += await connection.roundTrips(samples - times.count + 1, method: method).dropFirst().map(\.milliseconds) + connection.cancel() + } + return times + } onCancel: { + slot.cancel() } - self.continuation = nil - lock.unlock() - continuation.resume(returning: value) + result.best = times.min() + result.failures = samples - times.count + return result } } @@ -148,203 +149,141 @@ public struct SpeedResult: Sendable, Equatable, Codable { public var seconds: Double } -/// 下载与上传测速:边传边算,达到上限就掐断连接,不会把整份文件收完 +/// 下载与上传测速:边传边算,达到上限就掐断连接,不会把整份文件收完。 +/// 连接建在 Network.framework 上,直连时绑定物理网卡绕开代理,经代理时按系统设置走 public enum ThroughputProbe { - /// 开头的建连、慢启动不计入速度 + /// 开头的慢启动不计入速度 static let warmup: Double = 0.5 + /// 建连(含 TLS 握手)最多等多久;经代理时要多走一段,给宽一点 + static let connectTimeout: TimeInterval = 8 - public static func download(_ url: URL, limit: SpeedLimit, + public static func download(_ url: URL, limit: SpeedLimit, path: SpeedPath = .system, progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> SpeedResult? { - var request = URLRequest(url: url) - request.setValue("bytes=0-\(limit.bytes - 1)", forHTTPHeaderField: "Range") - request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: nil).result - } - - public static func upload(_ url: URL, bytes: Int, limit: SpeedLimit, - progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> SpeedResult? { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: bytes).result + await receive(url, limit: limit, path: path, range: true, repeats: false, progress: progress).result } /// 反复请求同一个地址直到用满上限,并读出响应头(Cloudflare 单次最多给 25 MB, /// 而且边缘节点信息就在响应头里) - static func downloadRepeatedly(_ url: URL, limit: SpeedLimit, + static func downloadRepeatedly(_ url: URL, limit: SpeedLimit, path: SpeedPath, progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> (result: SpeedResult?, headers: [String: String]) { - var request = URLRequest(url: url) - request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - return await SpeedMeter(limit: limit, progress: progress).run(request: request, uploadBytes: nil, repeats: true) + await receive(url, limit: limit, path: path, range: false, repeats: true, progress: progress) + } + + public static func upload(_ url: URL, bytes: Int, limit: SpeedLimit, path: SpeedPath = .system, + progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> SpeedResult? { + await guarded(limit: limit) { slot in + guard let connection = await SpeedConnection.open(url, path: path, timeout: connectTimeout) else { return nil } + slot.hold(connection) + defer { connection.cancel() } + var meter = SpeedMeter(limit: limit) + guard await connection.sendRequest("POST", headers: [("Content-Type", "application/octet-stream"), + ("Content-Length", String(bytes))]) else { return nil } + let complete = await connection.sendBody(bytes: bytes) { count in + meter.add(count) + progress(meter.rate, meter.bytes) + return !meter.reachedLimit + } + // 整段交给协议栈时还有一部分在发送缓冲区里,等服务器回了响应(收齐了)再停表 + if complete { _ = await connection.readResponse() } + return meter.result() + } ?? nil + } + + private static func receive(_ url: URL, limit: SpeedLimit, path: SpeedPath, range: Bool, repeats: Bool, + progress: @escaping @Sendable (Double, Int) -> Void) + async -> (result: SpeedResult?, headers: [String: String]) { + let headers = range ? [("Range", "bytes=0-\(limit.bytes - 1)")] : [] + return await guarded(limit: limit) { slot in + var meter = SpeedMeter(limit: limit) + var connection: SpeedConnection? + var responseHeaders: [String: String] = [:] + defer { connection?.cancel() } + while !slot.isCancelled { + if connection == nil { + connection = await SpeedConnection.open(url, path: path, timeout: connectTimeout) + guard let connection else { break } + slot.hold(connection) + } + guard let current = connection, await current.sendRequest("GET", headers: headers), + let response = await current.readResponse(), (200..<300).contains(response.status) else { break } + responseHeaders = response.headers + let complete = await current.readBody(response) { count in + meter.add(count) + progress(meter.rate, meter.bytes) + return !meter.reachedLimit + } + // 还没到上限、这次响应又正常收完:接着请求,把测量凑满 + guard repeats, complete, !meter.reachedLimit, meter.bytes > 0 else { break } + if !response.isReusable { + current.cancel() + connection = nil + } + } + return (meter.result(), responseHeaders) + } ?? (nil, [:]) + } + + /// 取消时掐断正在用的连接;数据卡住不动时看门狗到点也掐断。被取消的测量不给结果 + private static func guarded(limit: SpeedLimit, _ body: (ConnectionSlot) async -> T) async -> T? { + let slot = ConnectionSlot() + let watchdog = Task { + try? await Task.sleep(for: .seconds(limit.seconds + connectTimeout)) + slot.cancel() + } + defer { watchdog.cancel() } + let value = await withTaskCancellationHandler { + await body(slot) + } onCancel: { + slot.cancel() + } + return Task.isCancelled ? nil : value } } -/// 统计传输速度:记录起点与热身之后的字节数,达到上限就取消任务 -private final class SpeedMeter: NSObject, URLSessionDataDelegate, @unchecked Sendable { - private let limit: SpeedLimit - private let progress: @Sendable (Double, Int) -> Void +/// 统计传输速度:从第一笔数据开始计时,热身之后的部分才算速度;时间或流量到上限就该停 +struct SpeedMeter { + let limit: SpeedLimit private let clock = ContinuousClock() - private let lock = NSLock() - private var start: ContinuousClock.Instant? /// 热身结束时的时间与字节数,速度从这里开始算 private var mark: (time: ContinuousClock.Instant, bytes: Int)? - private var bytes = 0 - private var headers: [String: String] = [:] - private var finished = false - private var continuation: CheckedContinuation<(SpeedResult?, [String: String]), Never>? - private var task: URLSessionTask? - /// 单次请求给不满上限时(Cloudflare 一次最多 25 MB)继续下一次 - private var repeats = false - private var session: URLSession? - private var request: URLRequest? + private(set) var bytes = 0 + private(set) var reachedLimit = false - init(limit: SpeedLimit, progress: @escaping @Sendable (Double, Int) -> Void) { + init(limit: SpeedLimit) { self.limit = limit - self.progress = progress } - func run(request: URLRequest, uploadBytes: Int?, - repeats: Bool = false) async -> (result: SpeedResult?, headers: [String: String]) { - let configuration = URLSessionConfiguration.ephemeral - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.timeoutIntervalForRequest = limit.seconds + 10 - configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"] - let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) - defer { session.invalidateAndCancel() } - prepare(session: session, request: request, repeats: repeats) - - let pair: (SpeedResult?, [String: String]) = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - lock.lock() - self.continuation = continuation - start = clock.now - lock.unlock() - let task: URLSessionTask - if let uploadBytes { - task = session.uploadTask(with: request, from: Data(count: uploadBytes)) - } else { - task = session.dataTask(with: request) - } - lock.lock() - self.task = task - lock.unlock() - task.resume() - } - } onCancel: { - finish(cancelled: true) - } - return (pair.0, pair.1) - } - - /// 记下会话与请求,续传时还要再发一次(同步方法,异步上下文里不能直接拿锁) - private func prepare(session: URLSession, request: URLRequest, repeats: Bool) { - lock.lock() - self.session = session - self.request = request - self.repeats = repeats - lock.unlock() - } - - // MARK: 回调 - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, - completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - if let http = response as? HTTPURLResponse { - lock.lock() - headers = Dictionary(uniqueKeysWithValues: http.allHeaderFields.compactMap { key, value in - (key as? String).map { ($0.lowercased(), String(describing: value)) } - }) - lock.unlock() - } - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - advance(by: data.count) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, - totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { - advance(by: Int(bytesSent)) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - if error == nil, continueIfNeeded() { return } - finish(cancelled: false) - } - - /// 还没到上限、这次请求又正常结束:接着发下一次,把测量凑满 - private func continueIfNeeded() -> Bool { - lock.lock() - guard repeats, !finished, let start, let session, let request, bytes > 0, - bytes < limit.bytes, Self.seconds(from: start, to: clock.now) < limit.seconds else { - lock.unlock() - return false - } - let next = session.dataTask(with: request) - task = next - lock.unlock() - next.resume() - return true - } - - // MARK: 计算 - - private func advance(by count: Int) { - lock.lock() - bytes += count - guard let start, !finished else { return lock.unlock() } + mutating func add(_ count: Int) { let now = clock.now - let seconds = Self.seconds(from: start, to: now) + if start == nil { start = now } + bytes += count + let seconds = Self.seconds(start.map { now - $0 } ?? .zero) if mark == nil, seconds >= ThroughputProbe.warmup { mark = (now, bytes) } - let (speed, used) = (rate(now: now), bytes) - let reachedLimit = bytes >= limit.bytes || seconds >= limit.seconds - lock.unlock() - progress(speed, used) - if reachedLimit { finish(cancelled: false) } + reachedLimit = bytes >= limit.bytes || seconds >= limit.seconds } /// 热身之后的平均速度;还没过热身期就用全程平均,让界面上的数字先动起来 - private func rate(now: ContinuousClock.Instant) -> Double { + var rate: Double { guard let start else { return 0 } - let from = mark?.time ?? start - let base = mark?.bytes ?? 0 - let seconds = Self.seconds(from: from, to: now) + let seconds = Self.seconds(clock.now - (mark?.time ?? start)) guard seconds > 0.05 else { return 0 } - return Double(bytes - base) * 8 / seconds + return Double(bytes - (mark?.bytes ?? 0)) * 8 / seconds } - private func finish(cancelled: Bool) { - lock.lock() - guard !finished, let start else { return lock.unlock() } - finished = true + /// 截至此刻的结果;没收到数据或时间太短返回 nil + func result() -> SpeedResult? { + guard let start, bytes > 0 else { return nil } let now = clock.now - let result: SpeedResult? - if cancelled || bytes == 0 { - result = nil - } else { - let from = mark?.time ?? start - let seconds = Self.seconds(from: from, to: now) - let measured = bytes - (mark?.bytes ?? 0) - result = seconds > 0.05 && measured > 0 - ? SpeedResult(bitsPerSecond: Double(measured) * 8 / seconds, bytes: bytes, - seconds: Self.seconds(from: start, to: now)) - : nil - } - let continuation = self.continuation - let headers = self.headers - let task = self.task - self.continuation = nil - lock.unlock() - task?.cancel() - continuation?.resume(returning: (result, headers)) + let seconds = Self.seconds(now - (mark?.time ?? start)) + let measured = bytes - (mark?.bytes ?? 0) + guard seconds > 0.05, measured > 0 else { return nil } + return SpeedResult(bitsPerSecond: Double(measured) * 8 / seconds, bytes: bytes, seconds: Self.seconds(now - start)) } - private static func seconds(from: ContinuousClock.Instant, to: ContinuousClock.Instant) -> Double { - let duration = to - from - return Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + private static func seconds(_ duration: Duration) -> Double { + Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 } } @@ -374,13 +313,13 @@ public enum BroadbandTest { static let chunkMegabytes = 25 static let latencySamples = 10 - public static func run(limit: SpeedLimit, + public static func run(limit: SpeedLimit, path: SpeedPath = .system, stage: @escaping @Sendable (Stage) -> Void = { _ in }, progress: @escaping @Sendable (Double, Int) -> Void = { _, _ in }) async -> BroadbandResult { var result = BroadbandResult() stage(.latency) - let (samples, headers) = await latency() + let (samples, headers) = await latency(path: path) result.colo = headers["cf-meta-colo"] ?? headers["colo"] result.city = headers["cf-meta-city"] if !samples.isEmpty { @@ -395,7 +334,8 @@ public enum BroadbandTest { stage(.download) let url = URL(string: downURL.absoluteString + String(chunkMegabytes * 1024 * 1024))! - let (download, downloadHeaders) = await ThroughputProbe.downloadRepeatedly(url, limit: limit, progress: progress) + let (download, downloadHeaders) = await ThroughputProbe.downloadRepeatedly(url, limit: limit, path: path, + progress: progress) result.download = download result.totalBytes += download?.bytes ?? 0 result.colo = result.colo ?? downloadHeaders["cf-meta-colo"] ?? downloadHeaders["colo"] @@ -403,36 +343,46 @@ public enum BroadbandTest { stage(.upload) let uploadBytes = min(limit.bytes, uploadMegabytes * 1024 * 1024) - let upload = await ThroughputProbe.upload(upURL, bytes: uploadBytes, limit: limit, progress: progress) + let upload = await ThroughputProbe.upload(upURL, bytes: uploadBytes, limit: limit, path: path, + progress: progress) result.upload = upload result.totalBytes += upload?.bytes ?? 0 return result } - /// 连续请求空响应,用往返时间当延迟;第一次要建连、握手,不计入 - private static func latency() async -> ([Double], [String: String]) { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 5 - configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"] - let session = URLSession(configuration: configuration) - defer { session.invalidateAndCancel() } + /// 同一条连接上连续请求空响应,用往返时间当延迟。经代理时也准:每次都要等 Cloudflare 真的回话 + private static func latency(path: SpeedPath) async -> ([Double], [String: String]) { let url = URL(string: downURL.absoluteString + "0")! - let clock = ContinuousClock() - var samples: [Double] = [] - var headers: [String: String] = [:] - for index in 0.. Double { + (headers["server-timing"] ?? "").split(separator: ",").reduce(0) { total, entry in + let duration = entry.split(separator: ";").lazy + .map { $0.trimmingCharacters(in: .whitespaces) } + .first { $0.hasPrefix("dur=") } + .flatMap { Double($0.dropFirst(4)) } + return total + (duration ?? 0) } - return (samples, headers) } } diff --git a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestTransport.swift b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestTransport.swift new file mode 100644 index 0000000..f873632 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestTransport.swift @@ -0,0 +1,332 @@ +import Foundation +import Network + +// MARK: - 线路 + +/// 测速走哪条线路 +public enum SpeedRoute: String, Sendable, Codable, CaseIterable { + /// 绑定物理网卡、忽略系统代理:绕开 VPN 与代理,测的是本机宽带 + case direct + /// 按系统设置发出:开着 VPN 或代理就经过它,测的是代理线路 + case proxy +} + +/// 按当前网络环境定下来的一条测速线路。 +/// +/// 开着 Surge、Clash 这类代理时,连接先到本机的代理或隧道,TCP 握手由它当场应答, +/// 握手计时只剩零点几毫秒,与目标远近无关。所以要么绑定物理网卡绕开代理(直连), +/// 要么经代理时改用 HTTP 往返计时(请求必须真的到达目标、拿回响应) +public struct SpeedPath: @unchecked Sendable { + // NWInterface 没有标 Sendable,但它是不可变的值,跨任务传递是安全的 + + /// 绑定的物理网卡;nil 表示按系统路由与代理设置 + let interface: NWInterface? + /// 物理网卡上的 DNS 也被代理改成了 fake-ip 或本机地址:直连前先经这块网卡用 DoH 自己解析 + let resolvesItself: Bool + /// 这条线路经过代理或隧道,延迟只能用 HTTP 往返计时 + public let isProxied: Bool + /// 开着代理时这次实际走的线路;没有代理时为 nil,两条线路没有区别 + public let route: SpeedRoute? + + /// 没有代理:按系统路由走,TCP 握手计时就是真实往返 + public static let system = SpeedPath(interface: nil, resolvesItself: false, isProxied: false, route: nil) + + /// 选直连但找不到物理网卡(例如 VPN 不允许绕开)时退回经代理,`route` 如实记为 `.proxy` + public static func make(route: SpeedRoute, details: NetworkDetails, environment: ProxyEnvironment) async -> SpeedPath { + guard environment.hasProxy else { return .system } + if route == .direct, let interface = await EgressProber.physicalInterface(named: environment.physicalInterface) { + let hijacked = (details.physical?.manualDNS ?? []).contains(where: Self.isHijackedDNS) + return SpeedPath(interface: interface, resolvesItself: hijacked, isProxied: false, route: .direct) + } + return SpeedPath(interface: nil, resolvesItself: false, isProxied: true, route: .proxy) + } + + /// fake-ip 或本机回环:绑定物理网卡后这类 DNS 要么查不到、要么给出假地址 + static func isHijackedDNS(_ server: String) -> Bool { + ProxyEnvironment.isFakeIP(server) || server.hasPrefix("127.") || server == "::1" + } + + func apply(to parameters: NWParameters) { + guard let interface else { return } + parameters.requiredInterface = interface + parameters.preferNoProxies = true + } + + /// 需要自己解析时返回目标 IP;不需要时返回主机名本身,交给系统(绑定网卡后按这块网卡的 DNS 解析) + func address(for host: String) async -> String? { + guard resolvesItself, let interface, !PublicAddressLookup.isAddress(host) else { return host } + return await EgressProber.resolve(host, ipv6: false, interface: interface) + } +} + +// MARK: - HTTP 连接 + +/// 测速用的 HTTP/1.1 连接,建在 Network.framework 上:能绑定物理网卡、绕开系统代理(URLSession 做不到), +/// 一条连接上可以接连发多个请求。只实现测速用得到的部分:请求头、定长或读到关闭为止的响应体、上传定长数据。 +/// +/// 同一时间只由一个任务使用;取消靠 `cancel()`,挂着的收发会立刻以失败返回 +final class SpeedConnection: @unchecked Sendable { + struct Response { + var status: Int + var headers: [String: String] + + /// chunked 编码不解析,按读到关闭为止处理 + var contentLength: Int? { + headers["transfer-encoding"]?.lowercased().contains("chunked") == true + ? nil : headers["content-length"].flatMap { Int($0) } + } + + /// 读完响应体后这条连接还能接着发下一个请求 + var isReusable: Bool { + contentLength != nil && headers["connection"]?.lowercased() != "close" + } + } + + let url: URL + private let connection: NWConnection + private let queue = DispatchQueue(label: "com.openstats.speedtest.http", qos: .utility) + /// 已收到、还没交给调用方的字节 + private var buffer = Data() + + private init(url: URL, connection: NWConnection) { + self.url = url + self.connection = connection + } + + /// 建连(HTTPS 含 TLS 握手);连不上返回 nil + static func open(_ url: URL, path: SpeedPath, timeout: TimeInterval) async -> SpeedConnection? { + guard let host = url.host, let scheme = url.scheme, let address = await path.address(for: host) else { return nil } + let secure = scheme == "https" + let tcp = NWProtocolTCP.Options() + tcp.noDelay = true + tcp.connectionTimeout = Int(timeout.rounded(.up)) + var tls: NWProtocolTLS.Options? + if secure { + let options = NWProtocolTLS.Options() + sec_protocol_options_add_tls_application_protocol(options.securityProtocolOptions, "http/1.1") + // 连的是自己解析出的 IP 时,证书校验与 SNI 仍按域名 + if address != host { sec_protocol_options_set_tls_server_name(options.securityProtocolOptions, host) } + tls = options + } + let parameters = NWParameters(tls: tls, tcp: tcp) + path.apply(to: parameters) + guard let port = NWEndpoint.Port(rawValue: UInt16(url.port ?? (secure ? 443 : 80))) else { return nil } + let result = SpeedConnection(url: url, connection: NWConnection(host: NWEndpoint.Host(address), port: port, + using: parameters)) + guard await result.start(timeout: timeout) else { + result.cancel() + return nil + } + return result + } + + func cancel() { + connection.cancel() + } + + private func start(timeout: TimeInterval) async -> Bool { + let gate = ContinuationGate() + let ready = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + gate.attach(continuation) + connection.stateUpdateHandler = { state in + switch state { + case .ready: gate.resume(true) + // 路由不可达、被拒、等网络:都算连不上,不干等超时 + case .waiting, .failed, .cancelled: gate.resume(false) + default: break + } + } + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeout) { gate.resume(false) } + } + } onCancel: { + gate.resume(false) + } + connection.stateUpdateHandler = nil + return ready + } + + // MARK: 收发 + + func send(_ data: Data) async -> Bool { + await withCheckedContinuation { continuation in + connection.send(content: data, completion: .contentProcessed { error in + continuation.resume(returning: error == nil) + }) + } + } + + /// 收下一段数据;对方关闭、出错或连接被取消时返回 nil + private func receive() async -> Data? { + await withCheckedContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 256 * 1024) { data, _, _, _ in + continuation.resume(returning: data.flatMap { $0.isEmpty ? nil : $0 }) + } + } + } + + // MARK: HTTP + + /// 发请求头;上传的请求体由调用方随后用 `sendBody` 发 + func sendRequest(_ method: String, headers: [(String, String)] = []) async -> Bool { + var target = url.path(percentEncoded: true) + if target.isEmpty { target = "/" } + if let query = url.query(percentEncoded: true) { target += "?" + query } + var host = url.host ?? "" + if host.contains(":") { host = "[\(host)]" } + if let port = url.port { host += ":\(port)" } + let lines = ["\(method) \(target) HTTP/1.1", "Host: \(host)", "User-Agent: OpenStats", "Accept: */*", + "Accept-Encoding: identity", "Cache-Control: no-cache"] + + headers.map { "\($0.0): \($0.1)" } + return await send(Data((lines.joined(separator: "\r\n") + "\r\n\r\n").utf8)) + } + + /// 读到响应头结束;多收到的字节留在缓冲区,作为响应体的开头 + func readResponse() async -> Response? { + let separator = Data("\r\n\r\n".utf8) + var end = buffer.range(of: separator) + while end == nil { + // 响应头不会这么大,多半不是 HTTP + guard buffer.count < 64 * 1024, let data = await receive() else { return nil } + buffer.append(data) + end = buffer.range(of: separator) + } + guard let end else { return nil } + let head = String(decoding: buffer[buffer.startIndex..= 2, status[0].hasPrefix("HTTP/"), let code = Int(status[1]) else { return nil } + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard let colon = line.firstIndex(of: ":") else { continue } + let name = line[.. Bool = { _ in true }) async -> Bool { + if method == "HEAD" || response.status == 204 || response.status == 304 { return true } + let length = response.contentLength + var remaining = length ?? .max + if !buffer.isEmpty { + let take = min(buffer.count, remaining) + buffer = Data(buffer.dropFirst(take)) + remaining -= take + if !consume(take) { return remaining == 0 } + } + while remaining > 0 { + // 没有长度的响应读到对方关闭为止 + guard let data = await receive() else { return length == nil } + let take = min(data.count, remaining) + if take < data.count { buffer.append(data.dropFirst(take)) } + remaining -= take + if !consume(take) { return remaining == 0 } + } + return true + } + + /// 上传定长的空数据,分段发,每段交给协议栈后把字节数交给 `consume`;它返回 false 就停下。 + /// 返回是否整段发完 + func sendBody(bytes total: Int, consume: (Int) -> Bool) async -> Bool { + let chunk = Data(count: 64 * 1024) + var sent = 0 + while sent < total { + let size = min(chunk.count, total - sent) + guard await send(size == chunk.count ? chunk : chunk.prefix(size)) else { return false } + sent += size + if !consume(size) { return sent == total } + } + return true + } + + /// 在这条连接上接连发 `count` 个请求,返回每次的响应与从发出到读完响应的毫秒数;中途失败就停下 + func roundTrips(_ count: Int, method: String) async -> [(response: Response, milliseconds: Double)] { + let clock = ContinuousClock() + var trips: [(response: Response, milliseconds: Double)] = [] + for _ in 0.. Double { + Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15 + } +} + +// MARK: - 工具 + +/// 状态回调、超时与取消都可能同时到达,续体只能恢复一次;取消可能在续体挂上之前就到,先记下结果 +final class ContinuationGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var done = false + private var pending: Value? + + func attach(_ continuation: CheckedContinuation) { + lock.lock() + if let pending { + lock.unlock() + continuation.resume(returning: pending) + return + } + self.continuation = continuation + lock.unlock() + } + + func resume(_ value: Value) { + lock.lock() + guard !done else { return lock.unlock() } + done = true + guard let continuation else { + pending = value + lock.unlock() + return + } + self.continuation = nil + lock.unlock() + continuation.resume(returning: value) + } +} + +/// 当前在用的连接:任务取消或超时看门狗触发时从别的线程把它掐断 +final class ConnectionSlot: @unchecked Sendable { + private let lock = NSLock() + private var connection: SpeedConnection? + private var cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + /// 已经取消过就立刻掐断新连接 + func hold(_ connection: SpeedConnection) { + lock.lock() + self.connection = connection + let cancelled = cancelled + lock.unlock() + if cancelled { connection.cancel() } + } + + func cancel() { + lock.lock() + cancelled = true + let connection = connection + lock.unlock() + connection?.cancel() + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift index ab0cb74..beaff58 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift @@ -22,6 +22,7 @@ final class SpeedTestWindowController: NSObject, NSWindowDelegate { self.window = window if !isVisible { isVisible = true + model.speedTest.windowDidOpen() onVisibilityChange?(true) } NSApp.activate() diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift index ae5b1f2..fe42ffe 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift @@ -13,6 +13,7 @@ struct SpeedTestWindowView: View { .background(DS.Palette.background) .zIndex(1) PageScroll { + RouteBanner() BroadbandCard() ChinaLatencyCard() GlobalNodeCard() @@ -37,17 +38,18 @@ private struct SpeedTestHeader: View { Text(tr("网络测速")) .dsFont(.base, weight: .semibold) .foregroundStyle(DS.Palette.textPrimary) + .fixedSize() Spacer(minLength: DS.Space.s3) } .frame(maxHeight: .infinity) .background(WindowDragArea()) if speedTest.bytesUsed > 0 { + // 窗口窄时让它截断,不去挤标题 Text(tr("本次用掉 \(Format.bytes(Double(speedTest.bytesUsed)))")) .dsFont(.xs) .foregroundStyle(DS.Palette.textTertiary) .lineLimit(1) - .fixedSize() } SegmentedControl(selection: $settings.speedTestBudget, options: SpeedTestBudget.allCases.map { ($0, SpeedText.budget($0)) }) @@ -60,8 +62,8 @@ private struct SpeedTestHeader: View { .frame(height: DS.Size.windowHeader) } - /// 三颗红绿灯按钮加间距约 80pt,标题从它们右边再隔一段开始 - static let titleLeading = DS.Space.s16 * 5 + DS.Space.s4 + /// 三颗红绿灯按钮加间距约 80pt,标题从它们右边再隔一段开始(与出口与分流窗口一致) + static let titleLeading = DS.Size.trafficLightsWidth + DS.Space.s4 } /// 测速窗口里的一张卡片:图标标题行 + 一行说明 + 内容 @@ -107,6 +109,28 @@ private struct RunButton: View { } } +// MARK: - 线路 + +/// 开着 VPN / 代理时才出现:选直连(绕开它测本机宽带)还是经它测代理线路 +private struct RouteBanner: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + let speedTest = model.speedTest + if speedTest.hasProxy { + let name = speedTest.proxyName + InfoBanner(icon: "arrow.triangle.branch", + text: tr("检测到 \(name)。直连会绕开它,测的是本机宽带;经 \(name) 测的是代理线路")) { + SegmentedControl(selection: $settings.speedTestRoute, + options: [(.direct, tr("直连")), (.proxy, tr("经 \(name)"))]) + .fixedSize() + .onChange(of: settings.speedTestRoute) { speedTest.routeDidChange() } + } + } + } +} + // MARK: - 本机宽带 private struct BroadbandCard: View { @@ -155,13 +179,13 @@ private struct SpeedStatTile: View { let title: String let value: String var tone: Tone = .plain - /// 带运营商标记时,标题前面多一个品牌色小方块 - var badge: ChinaCarrier? + /// 带运营商时,标题前面多一个运营商标志 + var carrier: ChinaCarrier? var body: some View { VStack(alignment: .leading, spacing: DS.Space.s1) { HStack(spacing: DS.Space.s1) { - if let badge { CarrierBadge(carrier: badge) } + if let carrier { CarrierLogo(carrier: carrier) } Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } Text(verbatim: value) @@ -175,27 +199,22 @@ private struct SpeedStatTile: View { } } -/// 运营商标记:品牌色小方块 + 一个字。三家的正式标志是注册商标,不随应用打包 -private struct CarrierBadge: View { +/// 运营商标志,鼠标停留显示运营商名 +private struct CarrierLogo: View { let carrier: ChinaCarrier var body: some View { - Text(verbatim: L10n.isEnglish ? carrier.markEnglish : carrier.mark) - .dsFont(.xs, weight: .semibold) - .foregroundStyle(.white) - .frame(width: DS.Space.s6, height: DS.Size.iconInline) - .background(color, in: RoundedRectangle(cornerRadius: DS.Radius.sm, style: .continuous)) - .accessibilityLabel(SpeedText.carrier(carrier)) - .help(SpeedText.carrier(carrier)) - } - - /// 三家各自的品牌色 - private var color: Color { - switch carrier { - case .telecom: Color(red: 0, green: 0.36, blue: 0.67) - case .unicom: Color(red: 0.90, green: 0.07, blue: 0.09) - case .mobile: Color(red: 0.04, green: 0.63, blue: 0.86) + Group { + if let image = LogoCache.shared.image(named: "carrier-" + String(describing: carrier), template: false) { + Image(nsImage: image).resizable().scaledToFit() + } else { + Text(verbatim: SpeedText.carrier(carrier)).dsFont(.xs, weight: .semibold) + } } + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + .accessibilityElement() + .accessibilityLabel(SpeedText.carrier(carrier)) + .help(SpeedText.carrier(carrier)) } } @@ -209,16 +228,23 @@ private struct ChinaLatencyCard: View { SpeedSection(icon: "map", title: tr("国内分省三网延迟"), subtitle: speedTest.isTestingChina ? tr("正在测 \(speedTest.chinaProgress)/\(ChinaNode.all.count)") - : SpeedText.checkedAt(speedTest.chinaDate)) { + : SpeedText.status(route: speedTest.chinaRoute, date: speedTest.chinaDate, speedTest)) { RunButton(isRunning: speedTest.isTestingChina, title: tr("测延迟")) { speedTest.runChina() } } content: { + if let notice = speedTest.chinaNotice { + Text(notice).dsFont(.xs).foregroundStyle(DS.Palette.error) + } else if speedTest.hasProxy && speedTest.chinaLatency.isEmpty { + Text(tr("国内节点只能测 TCP 建连,经代理测不准,所以始终直连测")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } if !speedTest.chinaLatency.isEmpty { HStack(spacing: DS.Space.s3) { ForEach(ChinaCarrier.allCases, id: \.self) { carrier in let counts = speedTest.chinaReachable(carrier) SpeedStatTile(title: tr("中位延迟"), value: SpeedText.milliseconds(speedTest.chinaMedian(carrier)), - badge: carrier) + carrier: carrier) .help(tr("\(SpeedText.carrier(carrier)):\(counts.reachable)/\(counts.total) 个节点连得上")) } } @@ -227,7 +253,7 @@ private struct ChinaLatencyCard: View { HStack(spacing: DS.Space.s2) { Text(tr("省份")).frame(width: DS.Space.s16 * 2, alignment: .leading) ForEach(ChinaCarrier.allCases, id: \.self) { carrier in - CarrierBadge(carrier: carrier) + CarrierLogo(carrier: carrier) .frame(maxWidth: .infinity, alignment: .trailing) } } @@ -287,7 +313,7 @@ private struct GlobalNodeCard: View { SpeedSection(icon: "globe", title: tr("全球节点"), subtitle: speedTest.isTestingGlobal ? tr("正在测 \(speedTest.globalProgress)/\(GlobalNode.all.count)") - : SpeedText.checkedAt(speedTest.globalDate)) { + : SpeedText.status(route: speedTest.globalRoute, date: speedTest.globalDate, speedTest)) { RunButton(isRunning: speedTest.isTestingGlobal, title: tr("测延迟")) { speedTest.runGlobal() } } content: { ForEach(GlobalRegion.allCases, id: \.self) { region in @@ -432,10 +458,25 @@ private enum SpeedText { } } - /// 测过之后显示走的是哪个边缘节点、什么时候测的 + /// 测过之后显示走的线路、哪个边缘节点、什么时候测的 static func broadbandStatus(_ speedTest: SpeedTestController) -> String? { - guard let colo = speedTest.broadband?.colo else { return checkedAt(speedTest.broadbandDate) } - return tr("经 Cloudflare \(colo) 边缘节点") + (checkedAt(speedTest.broadbandDate).map { " · " + $0 } ?? "") + let colo = speedTest.broadband?.colo.map { tr("经 Cloudflare \($0) 边缘节点") } + let parts = [route(speedTest.broadbandRoute, speedTest), colo, checkedAt(speedTest.broadbandDate)].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + /// 线路与测量时间,例如“直连 · 17:34 测”;没有代理时只有时间 + static func status(route value: SpeedRoute?, date: Date?, _ speedTest: SpeedTestController) -> String? { + guard let checked = checkedAt(date) else { return nil } + return route(value, speedTest).map { $0 + " · " + checked } ?? checked + } + + static func route(_ route: SpeedRoute?, _ speedTest: SpeedTestController) -> String? { + switch route { + case .direct: tr("直连") + case .proxy: tr("经 \(speedTest.proxyName)") + case nil: nil + } } static func checkedAt(_ date: Date?) -> String? { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-mobile.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-mobile.svg new file mode 100644 index 0000000..3dbfeed --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-mobile.svg @@ -0,0 +1 @@ +中国移动 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-telecom.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-telecom.svg new file mode 100644 index 0000000..56a711f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-telecom.svg @@ -0,0 +1 @@ +中国电信 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-unicom.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-unicom.svg new file mode 100644 index 0000000..4d16dfe --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-unicom.svg @@ -0,0 +1 @@ +中国联通 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index d72af71..bfa48a5 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -424,6 +424,10 @@ public final class AppSettings { public var speedTestBudget: SpeedTestBudget { didSet { defaults.set(speedTestBudget.rawValue, forKey: Keys.speedTestBudget) } } + /// 开着 VPN / 代理时网络测速走哪条线路:直连绕开它测本机宽带,或经它测代理线路 + public var speedTestRoute: SpeedRoute { + didSet { defaults.set(speedTestRoute.rawValue, forKey: Keys.speedTestRoute) } + } /// 主窗口开着时在程序坞与 ⌘Tab 中显示图标;默认关闭,和其他菜单栏工具一样只在菜单栏运行 public var showDockIcon: Bool { didSet { defaults.set(showDockIcon, forKey: Keys.showDockIcon) } @@ -529,6 +533,7 @@ public final class AppSettings { appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system showDockIcon = defaults.bool(forKey: Keys.showDockIcon) speedTestBudget = defaults.string(forKey: Keys.speedTestBudget).flatMap(SpeedTestBudget.init(rawValue:)) ?? .full + speedTestRoute = defaults.string(forKey: Keys.speedTestRoute).flatMap(SpeedRoute.init(rawValue:)) ?? .direct cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate expandedPopoverSections = Set((defaults.stringArray(forKey: Keys.expandedPopoverSections) ?? []).compactMap(PopoverSection.init(rawValue:))) @@ -601,6 +606,7 @@ public final class AppSettings { static let appearance = "appearance" static let showDockIcon = "showDockIcon" static let speedTestBudget = "speedTestBudget" + static let speedTestRoute = "speedTestRoute" static let cleanPrefersTrash = "cleanPrefersTrash" static let menuBarLayout = "menuBarLayout" static let hiddenPopoverSections = "hiddenPopoverSections" diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift index 55658e2..ea6dd50 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift @@ -1,18 +1,35 @@ +import AppKit import Foundation import Localization import Metrics import Observation /// 网络测速:本机宽带、国内分省三网延迟、全球节点、全球探针看目标。 -/// 四块互相独立,各自可以单独跑、单独停;下载类测试有明确的时间与流量上限 +/// 四块互相独立,各自可以单独跑、单独停;下载类测试有明确的时间与流量上限。 +/// +/// 开着 VPN / 代理时,测速按设置走直连(绑定物理网卡绕开它)或经代理; +/// 国内三网只允许做 TCP 建连计时,经代理时握手由本机代理应答、测不出东西,所以始终直连 @MainActor @Observable public final class SpeedTestController { + // MARK: 线路 + + /// 当前网络的代理环境;开着 VPN / 代理时界面上给出线路选择 + public private(set) var environment: ProxyEnvironment? + public var hasProxy: Bool { environment?.hasProxy == true } + + /// 代理的名字:隧道的服务名(例如 Surge),其次是正在运行的代理软件 + public var proxyName: String { + environment?.tunnelName ?? environment?.apps.first ?? tr("代理") + } + // MARK: 本机宽带 public private(set) var broadband: BroadbandResult? public private(set) var broadbandDate: Date? public private(set) var broadbandStage: BroadbandTest.Stage? + /// 开着代理时这次走的线路,界面上标出来;没有代理时为 nil + public private(set) var broadbandRoute: SpeedRoute? /// 测试进行中的实时速率,bit/s public private(set) var liveBitsPerSecond: Double = 0 public var isTestingBroadband: Bool { broadbandStage != nil } @@ -23,6 +40,9 @@ public final class SpeedTestController { public private(set) var chinaDate: Date? public private(set) var chinaProgress = 0 public private(set) var isTestingChina = false + public private(set) var chinaRoute: SpeedRoute? + /// 绕不开 VPN、没法测时的说明 + public private(set) var chinaNotice: String? // MARK: 全球节点 @@ -31,6 +51,8 @@ public final class SpeedTestController { public private(set) var globalDate: Date? public private(set) var globalProgress = 0 public private(set) var isTestingGlobal = false + /// 全球节点延迟与下载测速走的线路 + public private(set) var globalRoute: SpeedRoute? /// 正在做下载测速的节点 public private(set) var downloadingNode: String? @@ -51,6 +73,8 @@ public final class SpeedTestController { @ObservationIgnored private var globalTask: Task? @ObservationIgnored private var downloadTask: Task? @ObservationIgnored private var probeTask: Task? + @ObservationIgnored private var watchTask: Task? + @ObservationIgnored private var details: NetworkDetails? @ObservationIgnored private let settings: AppSettings public init(settings: AppSettings) { @@ -59,11 +83,56 @@ public final class SpeedTestController { var limit: SpeedLimit { settings.speedTestBudget.limit } + /// 窗口开着时每隔几秒读一次代理环境,VPN 开关后线路选择跟着出现或消失 + func windowDidOpen() { + watchTask?.cancel() + watchTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshEnvironment() + try? await Task.sleep(for: .seconds(3)) + } + } + } + /// 关窗时停掉所有还在跑的测试,别让它继续占带宽 func windowDidClose() { + watchTask?.cancel() cancelAll() } + /// 换了线路,之前的结果属于另一条线路,全部清掉并停下正在跑的测试 + public func routeDidChange() { + cancelAll() + broadband = nil + broadbandDate = nil + broadbandRoute = nil + chinaLatency = [:] + chinaDate = nil + chinaRoute = nil + chinaNotice = nil + globalLatency = [:] + globalSpeed = [:] + globalDate = nil + globalRoute = nil + } + + private func refreshEnvironment() async { + let apps = NSWorkspace.shared.runningApplications.compactMap(\.localizedName) + let (details, environment) = await Task.detached { + let details = NetworkDetailsReader.read() + return (details, ProxyEnvironmentReader.read(details: details, runningAppNames: apps)) + }.value + self.details = details + if environment != self.environment { self.environment = environment } + } + + /// 定下这次测速走哪条路。每次开测前都重读网络环境,刚开关 VPN 也不会走错 + private func resolvePath(_ route: SpeedRoute) async -> SpeedPath { + await refreshEnvironment() + guard let details, let environment else { return .system } + return await SpeedPath.make(route: route, details: details, environment: environment) + } + public func cancelAll() { broadbandTask?.cancel() chinaTask?.cancel() @@ -88,14 +157,16 @@ public final class SpeedTestController { } broadbandStage = .latency liveBitsPerSecond = 0 - broadbandTask = Task { [limit] in - let result = await BroadbandTest.run(limit: limit) { stage in + broadbandTask = Task { [limit, route = settings.speedTestRoute] in + let path = await resolvePath(route) + let result = await BroadbandTest.run(limit: limit, path: path) { stage in Task { @MainActor [weak self] in self?.broadbandStage = stage; self?.liveBitsPerSecond = 0 } } progress: { speed, _ in Task { @MainActor [weak self] in self?.liveBitsPerSecond = speed } } guard !Task.isCancelled else { return stopBroadband() } broadband = result + broadbandRoute = path.route broadbandDate = Date() bytesUsed += result.totalBytes broadbandStage = nil @@ -110,7 +181,8 @@ public final class SpeedTestController { // MARK: 国内分省三网 - /// 93 个省级节点只做 TCP 建连计时,不下载数据 + /// 93 个省级节点只做 TCP 建连计时,不下载数据。 + /// 始终绑定物理网卡直连测:经代理时握手由本机代理当场应答,只剩零点几毫秒,与节点远近无关 public func runChina() { if isTestingChina { chinaTask?.cancel() @@ -119,31 +191,25 @@ public final class SpeedTestController { } isTestingChina = true chinaProgress = 0 + chinaLatency = [:] chinaTask = Task { - let nodes = ChinaNode.all - await withTaskGroup(of: (String, LatencyResult).self) { group in - var next = 0 - while next < min(Self.concurrency, nodes.count) { - group.addTask { [node = nodes[next]] in - (node.id, await TCPLatencyProbe.measure(host: node.host, port: node.port, - attempts: 2, timeout: 3)) - } - next += 1 - } - for await (id, result) in group { - chinaLatency[id] = result - chinaProgress += 1 - if Task.isCancelled { break } - if next < nodes.count { - group.addTask { [node = nodes[next]] in - (node.id, await TCPLatencyProbe.measure(host: node.host, port: node.port, - attempts: 2, timeout: 3)) - } - next += 1 - } - } - group.cancelAll() + let path = await resolvePath(.direct) + guard !path.isProxied else { + chinaNotice = tr("VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不出真实延迟。关掉 VPN 再测") + chinaDate = nil + isTestingChina = false + return } + chinaNotice = nil + let nodes = ChinaNode.all + await measure(nodes, concurrency: Self.concurrency) { node in + // 远距离线路偶尔丢一个握手包,多给一次机会、超时放宽一点 + await TCPLatencyProbe.measure(host: node.host, port: node.port, path: path, attempts: 3, timeout: 4) + } done: { node, result in + chinaLatency[node.id] = result + chinaProgress += 1 + } + chinaRoute = path.route chinaDate = Date() isTestingChina = false } @@ -166,6 +232,7 @@ public final class SpeedTestController { // MARK: 全球节点 + /// 直连时用 TCP 建连计时;经代理时握手由本机代理应答,改用 HTTP 往返计时 public func runGlobal() { if isTestingGlobal { globalTask?.cancel() @@ -174,36 +241,45 @@ public final class SpeedTestController { } isTestingGlobal = true globalProgress = 0 - globalTask = Task { - let nodes = GlobalNode.all - await withTaskGroup(of: (String, LatencyResult).self) { group in - var next = 0 - while next < min(Self.concurrency, nodes.count) { - group.addTask { [node = nodes[next]] in - (node.id, await TCPLatencyProbe.measure(host: node.host, port: node.port, - attempts: 3, timeout: 4)) - } - next += 1 - } - for await (id, result) in group { - globalLatency[id] = result - globalProgress += 1 - if Task.isCancelled { break } - if next < nodes.count { - group.addTask { [node = nodes[next]] in - (node.id, await TCPLatencyProbe.measure(host: node.host, port: node.port, - attempts: 3, timeout: 4)) - } - next += 1 - } - } - group.cancelAll() + globalLatency = [:] + globalTask = Task { [route = settings.speedTestRoute] in + let path = await resolvePath(route) + await measure(GlobalNode.all, concurrency: Self.concurrency) { node in + path.isProxied + ? await HTTPLatencyProbe.measure(node.downloadURL, path: path, samples: 3, timeout: 5) + : await TCPLatencyProbe.measure(host: node.host, port: node.port, path: path, attempts: 3, timeout: 4) + } done: { node, result in + globalLatency[node.id] = result + globalProgress += 1 } + globalRoute = path.route globalDate = Date() isTestingGlobal = false } } + /// 限定并发地逐个测量节点,每测完一个就回到主线程交结果;取消后不再发新的 + private func measure(_ nodes: [Node], concurrency: Int, + _ probe: @escaping @Sendable (Node) async -> LatencyResult, + done: (Node, LatencyResult) -> Void) async { + await withTaskGroup(of: (Node, LatencyResult).self) { group in + var next = 0 + while next < min(concurrency, nodes.count) { + group.addTask { [node = nodes[next]] in (node, await probe(node)) } + next += 1 + } + for await (node, result) in group { + done(node, result) + if Task.isCancelled { break } + if next < nodes.count { + group.addTask { [node = nodes[next]] in (node, await probe(node)) } + next += 1 + } + } + group.cancelAll() + } + } + /// 单个节点的下载测速,手动触发,一次只跑一个 public func download(_ node: GlobalNode) { if downloadingNode == node.id { @@ -215,8 +291,9 @@ public final class SpeedTestController { downloadTask?.cancel() downloadingNode = node.id liveBitsPerSecond = 0 - downloadTask = Task { [limit] in - let result = await ThroughputProbe.download(node.downloadURL, limit: limit) { speed, _ in + downloadTask = Task { [limit, route = settings.speedTestRoute] in + let path = await resolvePath(route) + let result = await ThroughputProbe.download(node.downloadURL, limit: limit, path: path) { speed, _ in Task { @MainActor [weak self] in self?.liveBitsPerSecond = speed } } guard !Task.isCancelled else { @@ -226,6 +303,7 @@ public final class SpeedTestController { } if let result { globalSpeed[node.id] = result + globalRoute = path.route bytesUsed += result.bytes } downloadingNode = nil diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift index 5454b5f..804cbd8 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift @@ -47,4 +47,33 @@ struct SpeedTestTests { #expect(Format.bandwidth(1_210_000_000) == "1.21 Gbps") #expect(Format.bandwidth(0) == "0 bps") } + + @Test func 扣掉Cloudflare报的服务器处理时间() { + let headers = ["server-timing": "cfSpeedEdge;dur=3, cfSpeedWorker;dur=21, cfL4;desc=\"?proto=TCP&rtt=65767\""] + #expect(BroadbandTest.serverMilliseconds(headers) == 24) + #expect(BroadbandTest.serverMilliseconds([:]) == 0) + } + + @Test func 物理网卡的DNS被代理接管时要自己解析() { + #expect(SpeedPath.isHijackedDNS("198.18.0.2")) + #expect(SpeedPath.isHijackedDNS("127.0.0.1")) + #expect(SpeedPath.isHijackedDNS("::1")) + #expect(!SpeedPath.isHijackedDNS("192.168.0.1")) + #expect(!SpeedPath.isHijackedDNS("223.5.5.5")) + } + + @Test func 没有代理时两条线路一样() async { + let details = NetworkDetails(physical: nil, tunnel: nil, dnsServers: []) + for route in SpeedRoute.allCases { + let path = await SpeedPath.make(route: route, details: details, environment: ProxyEnvironment()) + #expect(path.route == nil) + #expect(!path.isProxied) + } + // 经代理:延迟改用 HTTP 往返计时 + let proxied = await SpeedPath.make(route: .proxy, details: details, + environment: ProxyEnvironment(proxies: [.init(kind: .http, host: "127.0.0.1", port: 6152)])) + #expect(proxied.isProxied) + #expect(proxied.route == .proxy) + } } + diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 904c317..206b7ce 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -12,6 +12,11 @@ `google.svg` 是 Google 的品牌标志,仅按其品牌规范用于“使用 Google 登录”按钮。Apple 标志使用系统 SF Symbols 的 `apple.logo`。 GitHub、Google 与 Apple 的名称和标志均为各自公司的商标。 +## 运营商标志 + +`Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/carrier-*.svg` 是中国电信、中国联通、中国移动的标志, +仅在网络测速里用来标明各列延迟对应的运营商。三家的名称和标志均为各自公司的商标。 + ## exelban/stats OpenStats 的以下部分移植自 [exelban/stats](https://github.com/exelban/stats):