网络测速:开着 VPN 时可选直连或经代理,延迟不再被本机代理提前应答;三网换成运营商标志
This commit is contained in:
1 parent
d05b02be4f
commit
b98c670c16
15 files changed
+797
-345
No files matched your search
@@ -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
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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 节点,每省电信、联通、移动各一个。
|
||||
|
||||
@@ -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..<attempts {
|
||||
if Task.isCancelled { break }
|
||||
if let milliseconds = await connect(host: host, port: port, timeout: timeout) {
|
||||
if let milliseconds = await connect(host: address, port: port, path: path, timeout: timeout) {
|
||||
result.best = min(result.best ?? .greatestFiniteMagnitude, milliseconds)
|
||||
} else {
|
||||
result.failures += 1
|
||||
@@ -38,80 +45,74 @@ public enum TCPLatencyProbe {
|
||||
|
||||
private static let queue = DispatchQueue(label: "com.openstats.speedtest.tcp", qos: .utility)
|
||||
|
||||
private static func connect(host: String, port: UInt16, timeout: TimeInterval) async -> 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<Double?>()
|
||||
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<Double?, Never>?
|
||||
private var done = false
|
||||
private var pending: Double??
|
||||
|
||||
func attach(_ continuation: CheckedContinuation<Double?, Never>) {
|
||||
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<T: Sendable>(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..<latencySamples {
|
||||
if Task.isCancelled { break }
|
||||
let start = clock.now
|
||||
guard let (_, response) = try? await session.data(from: url) else { continue }
|
||||
let duration = clock.now - start
|
||||
// 前两次在建连、握手,不算延迟
|
||||
if index < 2 {
|
||||
headers = Dictionary(uniqueKeysWithValues: ((response as? HTTPURLResponse)?.allHeaderFields ?? [:])
|
||||
.compactMap { key, value in (key as? String).map { ($0.lowercased(), String(describing: value)) } })
|
||||
continue
|
||||
}
|
||||
samples.append(Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15)
|
||||
let slot = ConnectionSlot()
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: .seconds(ThroughputProbe.connectTimeout + 5))
|
||||
slot.cancel()
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
return await withTaskCancellationHandler {
|
||||
guard let connection = await SpeedConnection.open(url, path: path, timeout: ThroughputProbe.connectTimeout) else {
|
||||
return ([], [:])
|
||||
}
|
||||
slot.hold(connection)
|
||||
defer { connection.cancel() }
|
||||
let trips = await connection.roundTrips(latencySamples, method: "GET")
|
||||
// 前两次还在预热(经代理时代理这时才去连 Cloudflare),不算延迟;
|
||||
// 和 Cloudflare 自己的测速一样,扣掉响应头里报的服务器处理时间
|
||||
let samples = trips.dropFirst(2).map { max(0, $0.milliseconds - serverMilliseconds($0.response.headers)) }
|
||||
return (samples, trips.first?.response.headers ?? [:])
|
||||
} onCancel: {
|
||||
slot.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-Timing 里各段服务器处理耗时之和,例如 `cfSpeedEdge;dur=3, cfSpeedWorker;dur=21` 得 24
|
||||
static func serverMilliseconds(_ headers: [String: String]) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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<Bool>()
|
||||
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..<end.lowerBound], as: UTF8.self)
|
||||
buffer = Data(buffer[end.upperBound...])
|
||||
let lines = head.components(separatedBy: "\r\n")
|
||||
let status = lines.first?.split(separator: " ") ?? []
|
||||
guard status.count >= 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[..<colon].lowercased()
|
||||
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
// 同名的头(例如两行 Server-Timing)按 HTTP 的规矩用逗号拼起来
|
||||
headers[name] = headers[name].map { $0 + ", " + value } ?? value
|
||||
}
|
||||
return Response(status: code, headers: headers)
|
||||
}
|
||||
|
||||
/// 读响应体:每收到一段就把字节数交给 `consume`,它返回 false 就不再往下读(这条连接也就不能再用)。
|
||||
/// 返回是否读完了整个响应体
|
||||
func readBody(_ response: Response, method: String = "GET", consume: (Int) -> 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..<count {
|
||||
if Task.isCancelled { break }
|
||||
let start = clock.now
|
||||
guard await sendRequest(method), let response = await readResponse(),
|
||||
await readBody(response, method: method) else { break }
|
||||
trips.append((response, Self.milliseconds(clock.now - start)))
|
||||
if method != "HEAD", !response.isReusable { break }
|
||||
}
|
||||
return trips
|
||||
}
|
||||
|
||||
static func milliseconds(_ duration: Duration) -> Double {
|
||||
Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 工具
|
||||
|
||||
/// 状态回调、超时与取消都可能同时到达,续体只能恢复一次;取消可能在续体挂上之前就到,先记下结果
|
||||
final class ContinuationGate<Value: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Value, Never>?
|
||||
private var done = false
|
||||
private var pending: Value?
|
||||
|
||||
func attach(_ continuation: CheckedContinuation<Value, Never>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ final class SpeedTestWindowController: NSObject, NSWindowDelegate {
|
||||
self.window = window
|
||||
if !isVisible {
|
||||
isVisible = true
|
||||
model.speedTest.windowDidOpen()
|
||||
onVisibilityChange?(true)
|
||||
}
|
||||
NSApp.activate()
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="中国移动"><title>中国移动</title><path d="M835.776 579.776l-66.88-64.896s-15.52-11.52-33.056 5.184c0 0-64.48 62.912-119.424 113.472-52.16 47.36-104.32 96.32-123.84 110.272 0 0-84.384 86.4-191.328 1.984 0 0-145.664-114.24-289.792-310.528l-4.704 23.072s-5.376 19.136 7.36 37.056c0 0 112.576 166.016 251.072 285.056 0 0 69.024 73.632 157.344 89.568 0 0 72.864 19.904 142.112-35.84 33.856-25.856 107.52-94.72 169.216-153.28 56.96-54.144 101.92-101.12 101.92-101.12" fill="#0080CB"/><path d="M962.784 715.936l-58.944-64.896s-7.168-9.536-21.088 2.4c-13.92 11.936-195.488 188.704-264.768 236.48a232.16 232.16 0 0 1-129.76 51.744c-35.456 4-73.664-3.968-114.432-19.904-44.352-19.904-112.064-75.648-175.488-140.16-44.832-46.56-89.44-97.92-126.88-147.68-25.344-33.824-49.76-67.68-68.16-97.152 0 0 0 41.024 17.952 75.648 0 0 74.208 141.76 219.008 273.536 14.112 15.904 137.6 139.328 257.536 135.36 0 0 111.872 7.968 213.792-75.648 16.736-15.936 195.488-169.216 251.2-229.728M190.208 445.216l67.008 64.896s15.424 11.52 33.056-5.184c0 0 64.288-62.912 119.104-113.472 52.16-47.232 104.32-96.224 123.84-110.144 0 0 84.384-87.2 191.488-1.984 0 0 146.112 114.144 289.824 310.4l4.8-23.072s5.152-19.136-7.584-37.024c0 0-112.256-165.92-250.816-284.96 0 0-69.28-73.248-157.664-91.552 0 0-72.864-17.12-141.728 37.024-33.856 26.656-107.776 95.552-169.248 154.08-57.088 54.016-102.08 100.992-102.08 100.992" fill="#0080CB"/><path d="M62.88 309.184l58.88 64.896s7.424 9.536 21.248-2.4c13.792-11.936 195.584-188.32 264.768-238.08 39.84-28.256 82.432-46.592 129.408-49.76a237.632 237.632 0 0 1 114.656 19.52c44.576 19.904 112.288 74.432 175.584 140.512 44.576 46.176 89.568 97.536 127.008 147.2 25.056 33.824 49.76 67.68 68.064 97.152 0 0 0-41.024-17.92-75.648 0 0-74.432-141.632-218.976-275.392-14.336-12.736-137.728-136.576-257.6-133.76 0 0-111.84-6.4-213.856 77.216-16.544 13.952-195.392 168-251.264 228.544" fill="#0080CB"/><path d="M41.344 350.176s103.328 136.448 217.152 226.816c0 0 43.104 37.024 89.28-4.8l236.8-214.848s71.232-71.264 151.68-0.8c0 0 168.384 149.984 260.768 275.776 0 0 5.568 4.384 0 12.352-4.384 8.352-13.536 30.272-13.536 30.272s-101.12-131.008-218.176-227.744c0 0-45.376-34.624-84.8 1.984-39.04 36.64-224.544 206.24-254.016 227.744-29.44 21.504-78.272 44.16-147.296-17.536-53.984-48.16-195.2-181.536-250.784-265.536 0 0-4.992-4.8-0.992-13.824 5.728-10.752 13.92-29.856 13.92-29.856" fill="#9CC816"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="中国电信"><title>中国电信</title><path d="M984.830027 316.08971A511.889054 511.889054 0 0 0 0 511.942913a511.889054 511.889054 0 0 0 316.03585 472.940974 511.889054 511.889054 0 0 0 668.794177-276.86521 511.889054 511.889054 0 0 0 0-391.928967zM485.84948 564.467181c20.030441 231.240316 85.463216 224.340942 113.505834 57.198038l44.512091-10.905462c-49.185861 321.154741-249.712834 296.45053-273.304242-39.838322-231.017755-6.231693-264.846945-183.834938 16.024353-295.782849l9.79266 24.25909c-146.667342 62.762049-158.240486 151.118551-24.03653 166.697783 35.387113-239.475053 155.792321-294.892607 234.801284-139.100286l-23.368848 6.899374c-49.408422-38.72552-80.789446 27.152376-93.475393 127.527142 188.063587-46.292575 286.65787-165.807541 147.335023-194.072719l-8.012176-34.496871c304.462707 26.484695 214.325721 251.493318-143.774056 331.615082z" fill="#004EA1"/></svg>
|
||||
|
After Width: | Height: | Size: 972 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="中国联通"><title>中国联通</title><path d="M0 0m512 0l0 0q512 0 512 512l0 0q0 512-512 512l0 0q-512 0-512-512l0 0q0-512 512-512Z" fill="#F10000"/><path d="M653.056 615.168l-97.536-99.84-2.56-2.9696 7.7824-8.2432c37.0688-38.0928 74.1888-76.0832 111.104-114.3296 9.728-10.0864 20.1728-19.968 27.904-31.5392 15.5648-23.1424 17.6128-48.64 6.144-74.1376a109.6704 109.6704 0 0 0-23.6032-34.8672c-21.1968-20.2752-44.4416-35.4304-75.52-33.28-18.944 1.2288-35.4304 8.1408-49.2544 20.7872-10.496 9.728-20.0192 20.48-30.0032 30.72l-15.7184 15.9232c-0.8192-1.2288-1.024-1.792-1.4336-2.1504-16.4864-16.2816-31.744-34.048-49.664-48.4352-26.112-20.8896-55.6544-22.784-85.76-8.0896a122.2144 122.2144 0 0 0-51.5072 48.4864c-15.5136 25.9584-17.152 52.736-2.7648 79.9744 5.4784 10.3936 13.6192 18.2784 21.6576 26.5216 40.5504 41.216 80.8448 82.688 121.1904 124.16 2.56 2.6624 4.8128 5.632 7.5264 8.8064-3.328 3.584-6.2464 6.7584-9.216 9.8304-37.6832 38.5536-75.3664 77.0048-112.9984 115.6096-11.776 12.0832-24.32 23.3472-31.232 39.5776-9.4208 22.2208-9.216 44.1856 1.4336 65.3824 14.1312 28.2624 35.5328 49.2544 65.3824 61.0304 24.2176 9.5232 47.2576 5.5296 69.2736-6.7584 12.3392-6.8608 21.1456-17.92 30.8224-27.904 9.0624-9.216 18.432-18.0736 26.368-25.8048 12.3392 11.52 25.2928 22.272 36.7104 34.5088 14.4896 15.4624 31.0784 26.3168 52.1216 30.1056 21.4016 3.7888 40.704-2.1504 58.5216-13.4144 19.456-12.288 34.4576-28.672 45.3632-49.2544 16.2816-30.72 10.0864-65.1776-11.5712-90.3168-12.1344-14.08-25.9072-26.7776-38.912-40.0896zM401.0496 344.2176c-5.7856-5.9392-11.6736-11.776-17.3568-17.8176-10.9056-11.5712-8.3456-32.1536 4.4032-41.8304 15.7184-11.8784 30.3104-8.704 42.1376 4.8128 10.3936 11.8784 21.8112 22.8352 32.768 34.2016 1.4336 1.4336 2.816 2.9184 5.2736 5.5808l-33.536 30.72 45.9264 43.52 32.3072-29.8496 29.8496 31.488 45.4144-43.9296-31.744-33.2288 13.1072-13.4144c9.4208-9.728 18.688-19.6096 28.3648-29.0816 10.3936-10.24 21.4016-11.0592 33.5872-3.2768 13.2096 8.3456 17.7664 19.2512 13.9776 34.4576a21.1968 21.1968 0 0 1-4.864 9.3184c-41.2672 42.5984-82.688 84.992-124.1088 127.488l-4.9664 4.7104-21.1456-21.9648-89.3952-91.904z m231.168 398.6432a27.2384 27.2384 0 0 1-35.328-4.352c-13.4656-13.4144-27.392-26.3168-41.5744-39.8848l17.0496-18.176 15.8208-16.8448-45.9264-43.4176-29.2352 30.9248-33.024-30.5664-44.6464 42.8544 32 33.4848c-6.7072 7.0656-13.2096 14.08-19.8144 20.8896-6.5536 6.7584-13.4656 13.1584-19.7632 20.1216-13.0048 14.336-32.0512 11.6224-43.776-1.792a29.0816 29.0816 0 0 1 1.3824-39.6288c31.5904-31.744 62.6176-64.1024 93.9008-96.2048 8.2432-8.4992 16.5376-16.9984 24.576-25.7024 2.4576-2.56 4.3008-5.7344 6.3488-8.5504 9.728 9.472 17.92 17.2032 25.7536 25.2416 30.5152 31.232 60.928 62.6176 91.392 93.952 4.4544 4.608 9.0112 9.0624 13.312 13.7728 11.3664 12.1856 6.912 34.7136-8.448 43.8784z m256.3584-268.0832c-13.3632-42.5984-44.8-66.4576-88.2176-67.6352a101.6832 101.6832 0 0 0-73.4208 26.8288c-11.776 10.6496-22.528 22.4256-33.1776 33.1776l-40.0896-37.4272-44.6464 42.8544 39.6288 41.1648-40.4992 37.888 45.3632 43.52 41.2672-38.8096c0.2048 0.2048 0.768 0.7168 1.1776 1.28 10.9568 15.7184 24.5248 28.7744 39.936 40.1408 19.456 14.2848 41.1136 20.48 65.024 19.8144 22.2208-0.6144 42.2912-6.912 59.0336-21.8624 20.5824-18.2784 30.0544-42.24 34.0992-68.7616 2.7136-17.8176-0.1024-35.072-5.4784-52.224z m-60.416 62.2592c-9.8816 16.7424-28.5184 18.7392-43.008 14.336-17.3056-5.2224-30.0032-17.3056-42.1376-30.0544-9.5744-10.0864-9.3696-8.6016 0.1024-17.8688 8.2944-8.0896 16.896-16.0768 26.368-22.7328 12.0832-8.3968 25.856-12.2368 40.6016-7.6288 13.312 4.1472 25.1392 18.5856 24.1664 39.6288a41.8304 41.8304 0 0 1-6.0928 24.32z m-500.9408 21.4528c0.8192-1.2288 1.3312-2.6624 2.048-4.2496l39.4752 41.2672 47.104-44.6464-40.3456-37.5808 39.4752-41.0624-45.4144-43.9296c-12.8 13.312-25.856 27.0848-39.2192 41.0624-1.2288-1.1264-1.9456-1.6896-2.56-2.4064-8.704-9.7792-16.6912-20.4288-26.3168-29.2352-25.344-23.3472-55.04-34.2016-90.0608-29.4912-28.0064 3.7888-50.0736 16.7936-64.3072 40.8064-19.6608 33.1264-23.808 68.096-10.4448 105.1648 18.4832 51.2 62.208 69.7856 111.104 61.696 17.2544-2.8672 32.5632-11.3664 46.2336-22.3232 12.6464-10.1376 24.2688-21.4016 33.2288-35.072z m-40.7552-43.6224c-13.2608 14.6432-26.624 29.3888-46.08 35.9424-4.608 1.536-9.5232 2.048-14.2848 3.072a35.5328 35.5328 0 0 1-35.7888-32.6656c-1.3824-13.2096-0.768-25.856 7.68-37.0176 9.3696-12.288 29.8496-16.2816 45.2608-9.5744 17.408 7.68 30.1056 20.7872 42.5472 34.3552 1.1264 1.2288 1.5872 4.864 0.6656 5.888z" fill="#FFFFFF"/></svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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"
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@ObservationIgnored private var downloadTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var probeTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var watchTask: Task<Void, Never>?
|
||||
@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<Node: Sendable>(_ 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user