From 3b901e18cbd054433965e28e1b23ab62ec2c4bca Mon Sep 17 00:00:00 2001 From: gentpan Date: Fri, 18 Sep 2026 00:29:09 +0500 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BD=91=E7=BB=9C=E6=B5=8B?= =?UTF-8?q?=E9=80=9F=E7=AA=97=E5=8F=A3=EF=BC=9A=E6=9C=AC=E6=9C=BA=E5=AE=BD?= =?UTF-8?q?=E5=B8=A6=E3=80=81=E5=9B=BD=E5=86=85=E5=88=86=E7=9C=81=E4=B8=89?= =?UTF-8?q?=E7=BD=91=E5=BB=B6=E8=BF=9F=E3=80=81=E5=85=A8=E7=90=83=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E4=B8=8B=E8=BD=BD=E6=B5=8B=E9=80=9F=E4=B8=8E=E5=85=A8?= =?UTF-8?q?=E7=90=83=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/readme/activity.svg | 5 +- Assets/readme/activity.zh.svg | 5 +- CHANGELOG.md | 11 + .../Sources/Localization/Translations.swift | 34 ++ .../Sources/Metrics/Formatting.swift | 15 + .../Sources/Metrics/GlobalpingClient.swift | 168 +++++++ .../Sources/Metrics/SpeedTestNodes.swift | 184 +++++++ .../Sources/Metrics/SpeedTestProbe.swift | 438 +++++++++++++++++ .../OpenStatsUI/AppKit/AppController.swift | 18 +- .../AppKit/SpeedTestWindowController.swift | 53 ++ .../Sources/OpenStatsUI/Design/Tokens.swift | 3 + .../OpenStatsUI/Panel/MainWindowView.swift | 10 + .../Panel/SpeedTestWindowView.swift | 454 ++++++++++++++++++ .../Sources/OpenStatsUI/State/AppModel.swift | 3 + .../OpenStatsUI/State/AppSettings.swift | 6 + .../State/SpeedTestController.swift | 278 +++++++++++ .../Tests/MetricsTests/SpeedTestTests.swift | 50 ++ README.md | 26 +- README.zh-CN.md | 26 +- 19 files changed, 1760 insertions(+), 27 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/GlobalpingClient.swift create mode 100644 Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift create mode 100644 Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift create mode 100644 Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index c6f5a90..36efe98 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,5 +1,5 @@ -68 commits in the last 26 weeks +70 commits in the last 26 weeks Apr May @@ -190,8 +190,9 @@ 2 commits on 2026-09-14 12 commits on 2026-09-15 12 commits on 2026-09-16 +2 commits on 2026-09-17 -68 commits in the last 26 weeks · through 2026-09-16 +70 commits in the last 26 weeks · through 2026-09-17 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index f20f641..5159d96 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,5 +1,5 @@ -近 26 周共 68 次提交 +近 26 周共 70 次提交 4月 5月 @@ -190,8 +190,9 @@ 2026-09-14:2 次提交 2026-09-15:12 次提交 2026-09-16:12 次提交 +2026-09-17:2 次提交 -近 26 周共 68 次提交 · 截至 2026-09-16 +近 26 周共 70 次提交 · 截至 2026-09-17 少 diff --git a/CHANGELOG.md b/CHANGELOG.md index df5be25..4e42353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ ## 未发布 +### 2026-09-18 + +#### 新增 + +- 网络测速:网络页顶栏新增“网络测速”按钮,打开一个独立窗口,四块内容各自独立、可以单独跑也可以随时停。 + - 本机宽带:用 speed.cloudflare.com 测下行、上行、空载延迟与抖动,并显示这次走的是哪个 Cloudflare 边缘节点。 + - 国内分省三网延迟:31 个省份 × 电信 / 联通 / 移动共 93 个节点,只做 TCP 建连计时、不下载任何数据,给出三网各自的中位延迟与逐省数值。节点来自 zstaticcdn.com。 + - 全球节点:亚太、欧洲、北美等 24 个节点先测延迟,再对想看的节点单独测下载速度,测速文件来自 Linode、Vultr、DataPacket。 + - 全球探针看目标:填一个域名,从全球 10 / 20 / 30 个社区探针 ping 它,看各地访问的延迟与丢包。走 Globalping 的公开接口,请求从本机直接发出、不带任何密钥,用的是本机 IP 每小时的免费额度。 + - 下载类测试有明确的用量上限,顶栏可选 3 秒 / 20 MB、5 秒 / 50 MB、10 秒 / 200 MB(默认),时间与流量哪个先到就停在哪,顶栏实时显示本次用掉多少流量,关窗即停。 + ### 2026-09-17 #### 调整 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index b122251..aeeac1b 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -56,6 +56,7 @@ DNS 已设置为 {} DNS set to {} DNS 缓存已刷新 DNS cache flushed DerivedData,下次编译时自动重建 DerivedData; rebuilt on the next build GPU 占用 GPU Usage +Globalping 返回了错误(HTTP {}) Globalping returned an error (HTTP {}) Google(8.8.8.8) Google (8.8.8.8) IP 地址 IP Addresses IP 类型 IP type @@ -96,6 +97,7 @@ npm 缓存 npm Cache {} 个传感器 {} sensors {} 个应用,系统自带的不列出 {} apps; built-in apps are not listed {} 个进程 {} processes +{} 中位延迟 {} median {} 分钟 {} min {} 可用 {} · 共 {} {} {} free · {} total {} 天 {} d @@ -106,7 +108,9 @@ npm 缓存 npm Cache {} 核图形处理器 {}-core GPU {} 正在运行 {} is running {} 正在运行,卸载前需要先退出。 {} is running. Quit it before uninstalling. +{} 测 at {} {} 秒 {}s +{} 秒 / {} MB {}s / {} MB {} 组 {} groups {} 缓存 {} Cache {} 英寸 {}-inch @@ -115,6 +119,7 @@ npm 缓存 npm Cache {} 项 · 需要管理员权限,只读 {} items · needs admin, read-only {} 项失败 {} failed {}%(阈值 {}%) {}% (threshold {}%) +{}/{} 个节点连得上 {}/{} nodes reachable {}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge. {}{}({}) {}{} ({}) {}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically. @@ -143,6 +148,8 @@ npm 缓存 npm Cache 上次检查发现问题 Last check found problems 上次检查正常 Last check was fine 上次检查:{} Last checked: {} +上行 Upload +下行 Download 下载 Download 下载失败:{} Download failed: {} 下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓 .dmg / .pkg / .xip / .iso files in Downloads; moved to Trash @@ -157,6 +164,7 @@ npm 缓存 npm Cache 不限 No limit 不限时 Indefinitely 与 30 秒前持平 Same as 30s ago +丢包 {}% {}% loss 严重 Critical 中等负载 Moderate load 中等风险 Medium risk @@ -170,6 +178,7 @@ npm 缓存 npm Cache 介质错误 Media errors 从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here 从未 Never +从这个节点下载测速,上限 {} Download from this node, up to {} 代理 Proxy 以单核满载为 100% 100% = one full core 以后再说 Later @@ -213,6 +222,8 @@ npm 缓存 npm Cache 充电中 Charging 充电盒 Case 全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃) +全球探针看目标 Global Probes +全球节点 Global Nodes 全选 Select All 全部 All 全部删除 Delete all @@ -271,6 +282,7 @@ npm 缓存 npm Cache 包名是 {} Bundle ID is {} 包含城市数据 Include City Data 协议版本 Protocol version +单个节点一次测速最多花的时间与流量,先到哪个停哪个 Time and data cap for one node's speed test, whichever comes first 单行 Inline 单行文字 Inline Text 占整机 CPU 的比例:{} 个核心全部跑满为 100% Share of total CPU: all {} cores fully busy is 100% @@ -363,6 +375,7 @@ npm 缓存 npm Cache 启用失败:{} Failed to turn on: {} 唤醒 Wakeups 团队 {} team {} +国内分省三网延迟 Latency Across China 国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link 图形 Graphics 图形处理器 GPU @@ -379,6 +392,7 @@ npm 缓存 npm Cache 在菜单栏显示{} Show {} in menu bar 在访达中显示 Show in Finder 型号 Model +域名,例如 example.com Domain, for example example.com 处理器 Processor 处理器核心 CPU cores 备用空间 Available spare @@ -481,6 +495,7 @@ npm 缓存 npm Cache 开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep) Turn lid-closed sleep prevention on or off (same as pmset disablesleep) 开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置 Adds the icon to the menu bar; hold ⌘ and drag the icon to move it 开始分析 Analyze +开始探测 Run 开机以来 Since boot 开机后上传 Uploaded since boot 开机后下载 Downloaded since boot @@ -531,6 +546,7 @@ npm 缓存 npm Cache 扫描中 Scanning 扫描中… Scanning… 扫描于 Scanned +抖动 Jitter 折线历史 Line History 折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute) 拷贝 Copy @@ -549,6 +565,8 @@ npm 缓存 npm Cache 掌托 Palm rest 排队程度 Run Queue 探测目标 Probe Target +探针一直没有返回结果,稍后再试 The probes never returned a result; try again later +探针数量,一个探针占一次额度 Number of probes; each probe uses one test from the quota 接口 Interface 接通电源 Plugged in 推出 Eject @@ -633,7 +651,10 @@ npm 缓存 npm Cache 本地 IPv4 Local IPv4 本地 IPv6 Local IPv6 本地快照 Local snapshots +本机 IP 这一小时还剩 {}/{} 次探针额度 {}/{} probe tests left this hour for this Mac's IP 本机信息 This Mac +本机宽带 Your Connection +本次用掉 {} {} used 机型 Model 机型标识符 Model Identifier 机房 Data center @@ -688,6 +709,10 @@ npm 缓存 npm Cache 正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization… 正在检查… Checking… 正在检查更新 Checking for updates +正在测 {}/{} Testing {}/{} +正在测上行 Measuring upload +正在测下行 Measuring download +正在测延迟 Measuring latency 正在清理… Cleaning… 正在移除… Removing… 正在统计… Counting… @@ -726,6 +751,9 @@ npm 缓存 npm Cache 流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply. 浅色 Light 浅色菜单栏预览 Light menu bar preview +测延迟 Latency +测本机宽带、国内分省三网延迟与全球节点 Test your broadband, per-province latency in China and global nodes +测速 Test 浏览器 Browsers 深色 Dark 深色菜单栏预览 Dark menu bar preview @@ -800,6 +828,7 @@ npm 缓存 npm Cache 监控 Monitor 目标转速 Target speed 相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。 Like First Aid in Disk Utility, but read-only: checks that the startup disk's directory structure, allocation and snapshot metadata are consistent. +省份 Province 确认清理 Clean 确认通知能正常显示 Check that notifications appear 磁盘 Disk @@ -847,6 +876,7 @@ npm 缓存 npm Cache 线程 Threads 线程 {} Threads {} 线程数(只能读取自己的进程) Thread count (your processes only) +经 Cloudflare {} 边缘节点 Via the Cloudflare {} edge 结束 {} 失败:{} Failed to end {}: {} 结束“{}”? End “{}”? 结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked @@ -863,6 +893,7 @@ npm 缓存 npm Cache 网络名称 Network name 网络已恢复 Network Restored 网络断开 Network Down +网络测速 Network Speed Test 网络类型 Network type 网络详情打开时每秒 ping 一次,通了是绿格、不通是红格,显示最近 60 次 Pings once a second while network details are open: green when reachable, red when not, showing the last 60 网络运营方 Network operator @@ -913,6 +944,7 @@ npm 缓存 npm Cache 请从菜单退出 OpenStats Quit OpenStats from its menu 请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first. 请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。 Allow OpenStats in System Settings › General › Login Items. +请填写一个域名,例如 example.com Enter a domain, for example example.com 请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复 Open the built-in Disk Utility, select the startup disk and run First Aid to repair it 读 {} · 写 {} Read {} · Write {} 读写最多的应用 Top Disk Activity @@ -955,6 +987,8 @@ npm 缓存 npm Cache 还有 {} 个 {} more 还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量 Not enough records yet. The level is recorded every minute while Battery is in the menu bar or this page is open 还能用 {} {} left +这个小时的免费额度用完了,{} 分钟后可以再试 The free quota for this hour is used up; try again in {} minutes +这个小时的免费额度用完了,过一会儿再试 The free quota for this hour is used up; try again later 这个快捷键已被其他应用或系统占用,请换一个 This shortcut is used by another app or the system. Choose another. 这台 Mac 不提供功耗读数 This Mac doesn't report power 这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence. diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift index 58c54f6..70f4a1e 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -74,6 +74,21 @@ public enum Format { return "\(text) \(units[index])" } + /// 测速用的带宽:按 1000 进位,单位与运营商、测速站一致 + /// 例:820 kbps、95.3 Mbps、1.21 Gbps + public static func bandwidth(_ bitsPerSecond: Double) -> String { + let units = ["bps", "kbps", "Mbps", "Gbps"] + var value = max(0, bitsPerSecond) + guard value >= 1 else { return "0 bps" } + var index = 0 + while value >= 1000, index < units.count - 1 { + value /= 1000 + index += 1 + } + let digits = value >= 100 ? 0 : (value >= 10 ? 1 : 2) + return "\(value.formatted(.number.precision(.fractionLength(digits)))) \(units[index])" + } + public static func percent(_ fraction: Double) -> String { "\(Int((min(1, max(0, fraction)) * 100).rounded()))%" } diff --git a/Packages/OpenStatsKit/Sources/Metrics/GlobalpingClient.swift b/Packages/OpenStatsKit/Sources/Metrics/GlobalpingClient.swift new file mode 100644 index 0000000..7015db4 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/GlobalpingClient.swift @@ -0,0 +1,168 @@ +import Foundation + +/// 一个探针的测量结果 +public struct GlobalpingProbe: Sendable, Equatable, Identifiable, Codable { + public var id: String + public var continent: String + public var countryCode: String + public var city: String + /// 探针所在网络(运营商或机房) + public var network: String + /// 往返延迟的平均值,测不到时为 nil + public var milliseconds: Double? + /// 丢包百分比 + public var loss: Double? +} + +/// 一次全球探针测量 +public struct GlobalpingRun: Sendable, Equatable, Codable { + public var id: String + public var target: String + public var date: Date + public var probes: [GlobalpingProbe] + /// 本小时内还剩多少次额度(按调用方 IP 计) + public var remaining: Int? + public var limit: Int? +} + +/// Globalping 的公开接口:全球社区探针 ping 指定目标。 +/// +/// 这里**不带任何密钥**,匿名调用。额度按调用方 IP 计算(每小时 250 次,一个探针算一次), +/// 每台 Mac 用自己的额度,不经过我们的服务器,也不消耗我们的额度。 +/// +/// 注意:Globalping 的测量参数与结果是公开的,凭测量 ID 谁都能查到, +/// 所以只测用户自己填写的域名,不要把用户的公网 IP 发过去。 +public enum GlobalpingClient { + public enum Failure: Error, Sendable, Equatable { + /// 超出额度,附带需要等待的秒数 + case rateLimited(seconds: Int?) + case http(Int) + case invalidTarget + case timeout + } + + static let base = URL(string: "https://api.globalping.io/v1/measurements")! + /// 结果通常几秒内就绪,轮询间隔 0.5 秒也在官方限制(每个测量每秒 2 次)之内 + static let pollInterval: Duration = .milliseconds(500) + static let pollTimeout: Duration = .seconds(30) + + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 15 + configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"] + return URLSession(configuration: configuration) + }() + + /// 从全球按大洲分散挑 `probes` 个探针 ping 目标 + public static func ping(target: String, probes: Int) async throws -> GlobalpingRun { + let target = target.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty, !target.contains(" "), target.contains(".") else { throw Failure.invalidTarget } + + var request = URLRequest(url: base) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // 各大洲自带数量时不能再传总数,否则接口返回 400 + request.httpBody = try JSONEncoder().encode(Request(target: target, locations: distribution(probes))) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw Failure.http(0) } + let limits = rateLimits(http) + if http.statusCode == 429 { + throw Failure.rateLimited(seconds: http.value(forHTTPHeaderField: "Retry-After").flatMap(Int.init)) + } + guard http.statusCode == 202 || http.statusCode == 200, + let created = try? JSONDecoder().decode(Created.self, from: data) else { + throw Failure.http(http.statusCode) + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: pollTimeout) + while clock.now < deadline { + try? await Task.sleep(for: pollInterval) + try Task.checkCancellation() + guard let (data, response) = try? await session.data(from: base.appendingPathComponent(created.id)), + (response as? HTTPURLResponse)?.statusCode == 200, + let measurement = try? JSONDecoder().decode(Measurement.self, from: data) else { continue } + guard measurement.status != "in-progress" else { continue } + return GlobalpingRun(id: created.id, target: target, date: Date(), + probes: measurement.results.enumerated().map { index, item in + GlobalpingProbe(id: "\(created.id)-\(index)", + continent: item.probe.continent, + countryCode: item.probe.country, + city: item.probe.city, + network: item.probe.network, + milliseconds: item.result.stats?.avg, + loss: item.result.stats?.loss) + }, + remaining: limits.remaining, limit: limits.limit) + } + throw Failure.timeout + } + + static func rateLimits(_ response: HTTPURLResponse) -> (limit: Int?, remaining: Int?) { + (response.value(forHTTPHeaderField: "x-ratelimit-limit").flatMap(Int.init), + response.value(forHTTPHeaderField: "x-ratelimit-remaining").flatMap(Int.init)) + } + + /// 按大洲分配探针数,结果覆盖面更均匀;余数补给亚洲、欧洲、北美 + static func distribution(_ probes: Int) -> [Location] { + let weights: [(String, Double)] = [("AS", 0.3), ("EU", 0.25), ("NA", 0.25), ("SA", 0.08), ("OC", 0.06), ("AF", 0.06)] + var counts = weights.map { (continent: $0.0, limit: Int((Double(probes) * $0.1).rounded(.down))) } + var index = 0 + while counts.reduce(0, { $0 + $1.limit }) < probes { + counts[index % 3].limit += 1 + index += 1 + } + return counts.filter { $0.limit > 0 }.map { Location(continent: $0.continent, limit: $0.limit) } + } + + // MARK: 报文 + + struct Location: Codable, Sendable { + var continent: String + var limit: Int + } + + private struct Request: Codable { + var type = "ping" + var target: String + var locations: [Location] + var measurementOptions = Options() + + struct Options: Codable { + var packets = 4 + } + } + + private struct Created: Codable { + var id: String + } + + private struct Measurement: Codable { + var status: String + var results: [Item] + + struct Item: Codable { + var probe: Probe + var result: Result + } + + struct Probe: Codable { + var continent: String + var country: String + var city: String + var network: String + } + + struct Result: Codable { + var status: String? + var stats: Stats? + } + + struct Stats: Codable { + var min: Double? + var avg: Double? + var max: Double? + var loss: Double? + } + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift new file mode 100644 index 0000000..fea5897 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestNodes.swift @@ -0,0 +1,184 @@ +import Foundation + +// MARK: - 国内分省三网 + +/// 国内三大运营商 +public enum ChinaCarrier: String, Sendable, CaseIterable, Codable { + case telecom = "ct", unicom = "cu", mobile = "cm" + + public var name: String { + switch self { + case .telecom: "电信" + case .unicom: "联通" + case .mobile: "移动" + } + } + + public var nameEnglish: String { + switch self { + case .telecom: "Telecom" + case .unicom: "Unicom" + 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 节点,每省电信、联通、移动各一个。 +/// 该站点只允许用于连通性与延迟参考,所以这里只做 TCP 建连计时,不下载任何数据 +public struct ChinaNode: Sendable, Hashable, Identifiable { + public let province: String + public let provinceEnglish: String + /// 节点域名里的省份代码,如 bj、gd + public let code: String + public let carrier: ChinaCarrier + + public var id: String { "\(code)-\(carrier.rawValue)" } + public var host: String { "\(code)-\(carrier.rawValue)-v4.ip.zstaticcdn.com" } + public var port: UInt16 { 80 } + + /// 31 个省级行政区 × 三网 = 93 个节点 + public static let all: [ChinaNode] = provinces.flatMap { province in + ChinaCarrier.allCases.map { + ChinaNode(province: province.name, provinceEnglish: province.english, code: province.code, carrier: $0) + } + } + + public static let provinces: [(code: String, name: String, english: String)] = [ + ("bj", "北京", "Beijing"), ("tj", "天津", "Tianjin"), ("he", "河北", "Hebei"), ("sx", "山西", "Shanxi"), + ("nm", "内蒙古", "Inner Mongolia"), ("ln", "辽宁", "Liaoning"), ("jl", "吉林", "Jilin"), + ("hl", "黑龙江", "Heilongjiang"), ("sh", "上海", "Shanghai"), ("js", "江苏", "Jiangsu"), + ("zj", "浙江", "Zhejiang"), ("ah", "安徽", "Anhui"), ("fj", "福建", "Fujian"), ("jx", "江西", "Jiangxi"), + ("sd", "山东", "Shandong"), ("ha", "河南", "Henan"), ("hb", "湖北", "Hubei"), ("hn", "湖南", "Hunan"), + ("gd", "广东", "Guangdong"), ("gx", "广西", "Guangxi"), ("hi", "海南", "Hainan"), + ("cq", "重庆", "Chongqing"), ("sc", "四川", "Sichuan"), ("gz", "贵州", "Guizhou"), ("yn", "云南", "Yunnan"), + ("xz", "西藏", "Tibet"), ("sn", "陕西", "Shaanxi"), ("gs", "甘肃", "Gansu"), ("qh", "青海", "Qinghai"), + ("nx", "宁夏", "Ningxia"), ("xj", "新疆", "Xinjiang"), + ] +} + +// MARK: - 全球节点 + +/// 全球测速节点所在的大区,界面按此分组 +public enum GlobalRegion: String, Sendable, CaseIterable, Codable { + case asiaPacific, europe, northAmerica, other + + public var name: String { + switch self { + case .asiaPacific: "亚太" + case .europe: "欧洲" + case .northAmerica: "北美" + case .other: "其他地区" + } + } + + public var nameEnglish: String { + switch self { + case .asiaPacific: "Asia-Pacific" + case .europe: "Europe" + case .northAmerica: "North America" + case .other: "Other" + } + } +} + +/// 全球测速节点:各家云厂商公开提供的测速文件。 +/// 延迟用 TCP 建连计时;下载测速按用户设定的时长与流量上限截断,不会整份下载 +public struct GlobalNode: Sendable, Hashable, Identifiable { + public let id: String + public let city: String + public let cityEnglish: String + /// 两位国家代码,界面上显示国旗 + public let countryCode: String + public let region: GlobalRegion + /// 提供测速文件的服务商 + public let provider: String + public let downloadURL: URL + + public var host: String { downloadURL.host ?? "" } + public var port: UInt16 { 443 } + + init(_ id: String, _ city: String, _ cityEnglish: String, _ countryCode: String, + _ region: GlobalRegion, _ provider: String, _ url: String) { + self.id = id + self.city = city + self.cityEnglish = cityEnglish + self.countryCode = countryCode + self.region = region + self.provider = provider + // 清单写死在代码里,地址不合法属于编码错误 + downloadURL = URL(string: url)! + } + + /// 24 个节点,四家服务商,逐个验证过可用 + public static let all: [GlobalNode] = [ + // 亚太 + .init("tokyo", "东京", "Tokyo", "JP", .asiaPacific, "Linode", + "https://speedtest.tokyo2.linode.com/100MB-tokyo2.bin"), + .init("osaka", "大阪", "Osaka", "JP", .asiaPacific, "Linode", + "https://speedtest.osaka.linode.com/100MB-osaka.bin"), + .init("seoul", "首尔", "Seoul", "KR", .asiaPacific, "DataPacket", + "https://seo.download.datapacket.com/100mb.bin"), + .init("hongkong", "香港", "Hong Kong", "HK", .asiaPacific, "DataPacket", + "https://hkg.download.datapacket.com/100mb.bin"), + .init("singapore", "新加坡", "Singapore", "SG", .asiaPacific, "Linode", + "https://speedtest.singapore.linode.com/100MB-singapore.bin"), + .init("mumbai", "孟买", "Mumbai", "IN", .asiaPacific, "Linode", + "https://speedtest.mumbai1.linode.com/100MB-mumbai1.bin"), + .init("sydney", "悉尼", "Sydney", "AU", .asiaPacific, "Linode", + "https://speedtest.sydney.linode.com/100MB-sydney.bin"), + // 欧洲 + .init("frankfurt", "法兰克福", "Frankfurt", "DE", .europe, "Linode", + "https://speedtest.frankfurt.linode.com/100MB-frankfurt.bin"), + .init("london", "伦敦", "London", "GB", .europe, "Linode", + "https://speedtest.london.linode.com/100MB-london.bin"), + .init("amsterdam", "阿姆斯特丹", "Amsterdam", "NL", .europe, "Vultr", + "https://ams-nl-ping.vultr.com/vultr.com.100MB.bin"), + .init("paris", "巴黎", "Paris", "FR", .europe, "Linode", + "https://speedtest.paris.linode.com/100MB-paris.bin"), + .init("stockholm", "斯德哥尔摩", "Stockholm", "SE", .europe, "Linode", + "https://speedtest.stockholm.linode.com/100MB-stockholm.bin"), + .init("madrid", "马德里", "Madrid", "ES", .europe, "Linode", + "https://speedtest.madrid.linode.com/100MB-madrid.bin"), + .init("warsaw", "华沙", "Warsaw", "PL", .europe, "Vultr", + "https://waw-pl-ping.vultr.com/vultr.com.100MB.bin"), + // 北美 + .init("newark", "纽瓦克", "Newark", "US", .northAmerica, "Linode", + "https://speedtest.newark.linode.com/100MB-newark.bin"), + .init("chicago", "芝加哥", "Chicago", "US", .northAmerica, "DataPacket", + "https://chi.download.datapacket.com/100mb.bin"), + .init("miami", "迈阿密", "Miami", "US", .northAmerica, "DataPacket", + "https://mia.download.datapacket.com/100mb.bin"), + .init("dallas", "达拉斯", "Dallas", "US", .northAmerica, "Linode", + "https://speedtest.dallas.linode.com/100MB-dallas.bin"), + .init("seattle", "西雅图", "Seattle", "US", .northAmerica, "Linode", + "https://speedtest.seattle.linode.com/100MB-seattle.bin"), + .init("losangeles", "洛杉矶", "Los Angeles", "US", .northAmerica, "Vultr", + "https://lax-ca-us-ping.vultr.com/vultr.com.100MB.bin"), + .init("toronto", "多伦多", "Toronto", "CA", .northAmerica, "Linode", + "https://speedtest.toronto1.linode.com/100MB-toronto1.bin"), + // 其他地区 + .init("saopaulo", "圣保罗", "São Paulo", "BR", .other, "Linode", + "https://speedtest.sao-paulo.linode.com/100MB-sao-paulo.bin"), + .init("mexicocity", "墨西哥城", "Mexico City", "MX", .other, "Vultr", + "https://mex-mx-ping.vultr.com/vultr.com.100MB.bin"), + .init("johannesburg", "约翰内斯堡", "Johannesburg", "ZA", .other, "Vultr", + "https://jnb-za-ping.vultr.com/vultr.com.100MB.bin"), + ] +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift new file mode 100644 index 0000000..78358f6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/SpeedTestProbe.swift @@ -0,0 +1,438 @@ +import Foundation +import Network + +// MARK: - 延迟 + +/// 一组 TCP 建连计时的结果 +public struct LatencyResult: Sendable, Codable, Equatable { + /// 最快一次的毫秒数;全部失败时为 nil + public var best: Double? + public var attempts: Int + public var failures: Int + + public var isReachable: Bool { best != nil } + + public init(best: Double? = nil, attempts: Int = 0, failures: Int = 0) { + self.best = best + self.attempts = attempts + self.failures = failures + } +} + +/// 用 TCP 建连的耗时代替 ping:目标节点大多不回 ICMP,握手时间同样能反映往返延迟。 +/// 只建连、不发数据,连上立即断开 +public enum TCPLatencyProbe { + public static func measure(host: String, port: UInt16, attempts: Int = 3, + timeout: TimeInterval = 3) async -> LatencyResult { + var result = LatencyResult(attempts: attempts) + for _ in 0.. 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 clock = ContinuousClock() + let start = clock.now + let once = ResumeOnce() + let milliseconds: Double? = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + once.attach(continuation) + connection.stateUpdateHandler = { state in + switch state { + case .ready: + once.resume(Self.elapsed(from: start, clock: clock)) + case .failed, .cancelled: + once.resume(nil) + case .waiting: + // 路由不可达、端口被拒:不再等超时,直接算这次失败 + once.resume(nil) + default: + break + } + } + connection.start(queue: queue) + // connectionTimeout 在个别情况下不触发状态变化,兜一个自己的超时 + queue.asyncAfter(deadline: .now() + timeout) { once.resume(nil) } + } + } onCancel: { + once.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 + } + 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 + } + self.continuation = nil + lock.unlock() + continuation.resume(returning: value) + } +} + +// MARK: - 吞吐 + +/// 一次测速的用量上限:时间与流量哪个先到就停在哪 +public struct SpeedLimit: Sendable, Equatable, Codable { + public var seconds: Double + public var bytes: Int + + public init(seconds: Double, megabytes: Int) { + self.seconds = seconds + bytes = megabytes * 1024 * 1024 + } +} + +/// 单个节点一次测速最多花多少时间与流量,设置里可选 +public enum SpeedTestBudget: String, Sendable, CaseIterable, Codable { + case light, medium, full + + public var limit: SpeedLimit { + switch self { + case .light: SpeedLimit(seconds: 3, megabytes: 20) + case .medium: SpeedLimit(seconds: 5, megabytes: 50) + case .full: SpeedLimit(seconds: 10, megabytes: 200) + } + } +} + +public struct SpeedResult: Sendable, Equatable, Codable { + public var bitsPerSecond: Double + /// 实际用掉的流量 + public var bytes: Int + public var seconds: Double +} + +/// 下载与上传测速:边传边算,达到上限就掐断连接,不会把整份文件收完 +public enum ThroughputProbe { + /// 开头的建连、慢启动不计入速度 + static let warmup: Double = 0.5 + + public static func download(_ url: URL, limit: SpeedLimit, + 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 + } + + /// 反复请求同一个地址直到用满上限,并读出响应头(Cloudflare 单次最多给 25 MB, + /// 而且边缘节点信息就在响应头里) + static func downloadRepeatedly(_ url: URL, limit: SpeedLimit, + 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) + } +} + +/// 统计传输速度:记录起点与热身之后的字节数,达到上限就取消任务 +private final class SpeedMeter: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let limit: SpeedLimit + private let progress: @Sendable (Double, Int) -> Void + 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? + + init(limit: SpeedLimit, progress: @escaping @Sendable (Double, Int) -> Void) { + 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() } + let now = clock.now + let seconds = Self.seconds(from: start, to: now) + 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) } + } + + /// 热身之后的平均速度;还没过热身期就用全程平均,让界面上的数字先动起来 + private func rate(now: ContinuousClock.Instant) -> 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) + guard seconds > 0.05 else { return 0 } + return Double(bytes - base) * 8 / seconds + } + + private func finish(cancelled: Bool) { + lock.lock() + guard !finished, let start else { return lock.unlock() } + finished = true + 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)) + } + + 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 + } +} + +// MARK: - 本机宽带(Cloudflare) + +/// 本机宽带测速的结果:下行、上行、空载延迟与抖动,以及这次走的 Cloudflare 边缘节点 +public struct BroadbandResult: Sendable, Equatable, Codable { + public var download: SpeedResult? + public var upload: SpeedResult? + public var latencyMilliseconds: Double? + public var jitterMilliseconds: Double? + public var colo: String? + public var city: String? + public var totalBytes: Int = 0 +} + +/// 用 speed.cloudflare.com 测本机宽带:先测延迟与抖动,再测下行、上行。 +/// 这是官方测速站自己用的接口,不需要密钥 +public enum BroadbandTest { + public enum Stage: String, Sendable { case latency, download, upload } + + static let downURL = URL(string: "https://speed.cloudflare.com/__down?bytes=")! + static let upURL = URL(string: "https://speed.cloudflare.com/__up")! + /// 上行的数据要在内存里生成,单独设一个上限 + static let uploadMegabytes = 25 + /// 下行单次请求的大小:再大 Cloudflare 会直接拒绝,凑上限靠多请求几次 + static let chunkMegabytes = 25 + static let latencySamples = 10 + + public static func run(limit: SpeedLimit, + 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() + result.colo = headers["cf-meta-colo"] ?? headers["colo"] + result.city = headers["cf-meta-city"] + if !samples.isEmpty { + result.latencyMilliseconds = samples.min() + // 抖动看相邻两次的差;偶尔一次重新建连会拉出一个大值,去掉最大的那个差 + let gaps = zip(samples, samples.dropFirst()).map { abs($1 - $0) }.sorted().dropLast() + if !gaps.isEmpty { + result.jitterMilliseconds = gaps.reduce(0, +) / Double(gaps.count) + } + } + guard !Task.isCancelled else { return result } + + stage(.download) + let url = URL(string: downURL.absoluteString + String(chunkMegabytes * 1024 * 1024))! + let (download, downloadHeaders) = await ThroughputProbe.downloadRepeatedly(url, limit: limit, progress: progress) + result.download = download + result.totalBytes += download?.bytes ?? 0 + result.colo = result.colo ?? downloadHeaders["cf-meta-colo"] ?? downloadHeaders["colo"] + guard !Task.isCancelled else { return result } + + stage(.upload) + let uploadBytes = min(limit.bytes, uploadMegabytes * 1024 * 1024) + let upload = await ThroughputProbe.upload(upURL, bytes: uploadBytes, limit: limit, 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() } + let url = URL(string: downURL.absoluteString + "0")! + let clock = ContinuousClock() + var samples: [Double] = [] + var headers: [String: String] = [:] + for index in 0.. 打开测速窗口并立即用全球探针 ping 这个域名 + if let index = arguments.firstIndex(of: "--probe"), let domain = arguments.dropFirst(index + 1).first { + speedTestWindow.show() + model.speedTest.globalpingTarget = domain + model.speedTest.runGlobalping() + } // 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程 if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first { mainWindow.show(tab: .processes) @@ -161,7 +177,7 @@ public final class AppController: NSObject, NSApplicationDelegate { /// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里 private func updateActivationPolicy() { - let hasWindow = mainWindow.isVisible + let hasWindow = mainWindow.isVisible || speedTestWindow.isVisible let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory guard NSApp.activationPolicy() != policy else { return } NSApp.setActivationPolicy(policy) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift new file mode 100644 index 0000000..ab0cb74 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/SpeedTestWindowController.swift @@ -0,0 +1,53 @@ +import AppKit +import Localization +import SwiftUI + +/// 网络测速窗口。关窗时停掉还在跑的测试,不让它继续占带宽 +@MainActor +final class SpeedTestWindowController: NSObject, NSWindowDelegate { + private let model: AppModel + private var window: NSWindow? + var onVisibilityChange: ((Bool) -> Void)? + + init(model: AppModel) { + self.model = model + super.init() + } + + /// windowWillClose 时窗口还算可见,所以自己记开关状态 + private(set) var isVisible = false + + func show() { + let window = self.window ?? makeWindow() + self.window = window + if !isVisible { + isVisible = true + onVisibilityChange?(true) + } + NSApp.activate() + window.makeKeyAndOrderFront(nil) + } + + private func makeWindow() -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: DS.Size.speedTestWindowWidth, + height: DS.Size.speedTestWindowHeight), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.title = tr("网络测速") + WindowChrome.apply(to: window) + window.isReleasedWhenClosed = false + window.delegate = self + window.contentMinSize = NSSize(width: DS.Size.panelWidth - DS.Space.s16, height: DS.Size.panelMinHeight * 1.5) + window.contentView = NSHostingView(rootView: SpeedTestWindowView().environment(model)) + // 第一次打开居中,之后沿用用户调整过的位置与大小 + if !window.setFrameUsingName("OpenStatsSpeedTestWindow") { window.center() } + window.setFrameAutosaveName("OpenStatsSpeedTestWindow") + return window + } + + func windowWillClose(_ notification: Notification) { + isVisible = false + model.speedTest.windowDidClose() + onVisibilityChange?(false) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 80ae627..ec257ff 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -168,6 +168,9 @@ extension DS { static let panelGap: CGFloat = 4 /// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt static let windowHeader: CGFloat = DS.Space.s12 + DS.Space.s1 + /// 网络测速窗口 + static let speedTestWindowWidth: CGFloat = 720 + static let speedTestWindowHeight: CGFloat = 840 /// 主窗口首次打开的最小高度(屏幕放得下时) static let windowDefaultHeight: CGFloat = 720 /// 主窗口默认内容区宽度(侧边栏另计) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index 644b3e4..a2af139 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -226,6 +226,16 @@ private struct PageHeader: View { .dsGlass(in: Capsule(), fallback: DS.Palette.primary.opacity(0.12)) } if tab == .memory { PurgeMemoryButton() } + if tab == .network { + Button { model.openSpeedTestWindow() } label: { + HStack(spacing: DS.Space.s1) { + Image(systemName: "gauge.with.dots.needle.67percent") + Text(tr("网络测速")).lineLimit(1).fixedSize() + } + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .help(tr("测本机宽带、国内分省三网延迟与全球节点")) + } // 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签 let items = tab.menuBarItems if !items.isEmpty { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift new file mode 100644 index 0000000..ae5b1f2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/SpeedTestWindowView.swift @@ -0,0 +1,454 @@ +import Localization +import Metrics +import SwiftUI + +/// 网络测速窗口:本机宽带、国内分省三网延迟、全球节点、全球探针看目标 +struct SpeedTestWindowView: View { + @Environment(AppModel.self) private var model + + var body: some View { + VStack(spacing: 0) { + // 滚动内容会延伸到标题栏下面:顶栏垫底色压在上面,红绿灯按钮才不会被盖住 + SpeedTestHeader() + .background(DS.Palette.background) + .zIndex(1) + PageScroll { + BroadbandCard() + ChinaLatencyCard() + GlobalNodeCard() + GlobalpingCard() + } + } + .background(DS.Palette.background) + .ignoresSafeArea() + } +} + +// MARK: - 顶栏 + +private struct SpeedTestHeader: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + let speedTest = model.speedTest + HStack(spacing: DS.Space.s3) { + HStack(spacing: 0) { + Text(tr("网络测速")) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + 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)) }) + .frame(width: DS.Size.sidebarWidth + DS.Space.s16 * 2 + DS.Space.s4) + .help(tr("单个节点一次测速最多花的时间与流量,先到哪个停哪个")) + } + // 让出红绿灯按钮,再隔开一段,标题不贴着最右边的按钮 + .padding(.leading, Self.titleLeading) + .padding(.trailing, DS.Space.s3 + DS.Space.s1) + .frame(height: DS.Size.windowHeader) + } + + /// 三颗红绿灯按钮加间距约 80pt,标题从它们右边再隔一段开始 + static let titleLeading = DS.Space.s16 * 5 + DS.Space.s4 +} + +/// 测速窗口里的一张卡片:图标标题行 + 一行说明 + 内容 +private struct SpeedSection: View { + let icon: String + let title: String + var subtitle: String? + @ViewBuilder var trailing: Trailing + @ViewBuilder var content: Content + + var body: some View { + Card { + CardHeader(icon: icon, title: title) { trailing } + if let subtitle { + Text(subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + content + } + } +} + +/// 开始 / 停止:跑起来之后同一个按钮变成停止 +private struct RunButton: View { + let isRunning: Bool + var title: String = tr("开始") + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: DS.Space.s1) { + if isRunning { + ProgressView().controlSize(.mini) + Text(tr("停止")) + } else { + Image(systemName: "play.fill") + Text(title) + } + } + .lineLimit(1) + .fixedSize() + } + .buttonStyle(DSButtonStyle(kind: isRunning ? .secondary : .primary)) + } +} + +// MARK: - 本机宽带 + +private struct BroadbandCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let speedTest = model.speedTest + let result = speedTest.broadband + SpeedSection(icon: "gauge.with.dots.needle.67percent", title: tr("本机宽带"), + subtitle: SpeedText.broadbandStatus(speedTest)) { + RunButton(isRunning: speedTest.isTestingBroadband, title: tr("测速")) { speedTest.runBroadband() } + } content: { + if speedTest.isTestingBroadband { + LiveSpeed(bitsPerSecond: speedTest.liveBitsPerSecond, stage: speedTest.broadbandStage) + } + HStack(alignment: .top, spacing: DS.Space.s3) { + SpeedStatTile(title: tr("下行"), value: SpeedText.rate(result?.download?.bitsPerSecond), tone: .primary) + SpeedStatTile(title: tr("上行"), value: SpeedText.rate(result?.upload?.bitsPerSecond), tone: .primary) + SpeedStatTile(title: tr("延迟"), value: SpeedText.milliseconds(result?.latencyMilliseconds), tone: .plain) + SpeedStatTile(title: tr("抖动"), value: SpeedText.milliseconds(result?.jitterMilliseconds), tone: .plain) + } + } + } +} + +/// 测试进行中的实时速率 +private struct LiveSpeed: View { + let bitsPerSecond: Double + let stage: BroadbandTest.Stage? + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { + Text(verbatim: SpeedText.rate(bitsPerSecond > 0 ? bitsPerSecond : nil)) + .dsFont(.xl, weight: .semibold) + .foregroundStyle(DS.Palette.primary) + .monospacedDigit() + Text(SpeedText.stage(stage)).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) + Spacer(minLength: 0) + } + } +} + +/// 指标块:上面小标题,下面大数字 +private struct SpeedStatTile: View { + enum Tone { case primary, plain } + let title: String + let value: String + var tone: Tone = .plain + /// 带运营商标记时,标题前面多一个品牌色小方块 + var badge: ChinaCarrier? + + var body: some View { + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s1) { + if let badge { CarrierBadge(carrier: badge) } + Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + Text(verbatim: value) + .dsFont(.lg, weight: .semibold) + .foregroundStyle(tone == .primary ? DS.Palette.primary : DS.Palette.textPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// 运营商标记:品牌色小方块 + 一个字。三家的正式标志是注册商标,不随应用打包 +private struct CarrierBadge: 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) + } + } +} + +// MARK: - 国内分省三网 + +private struct ChinaLatencyCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let speedTest = model.speedTest + SpeedSection(icon: "map", title: tr("国内分省三网延迟"), + subtitle: speedTest.isTestingChina + ? tr("正在测 \(speedTest.chinaProgress)/\(ChinaNode.all.count)") + : SpeedText.checkedAt(speedTest.chinaDate)) { + RunButton(isRunning: speedTest.isTestingChina, title: tr("测延迟")) { speedTest.runChina() } + } content: { + 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) + .help(tr("\(SpeedText.carrier(carrier)):\(counts.reachable)/\(counts.total) 个节点连得上")) + } + } + HairlineDivider() + VStack(spacing: 0) { + 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) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .padding(.vertical, DS.Space.s1) + ForEach(ChinaNode.provinces, id: \.code) { province in + HStack(spacing: DS.Space.s2) { + Text(L10n.isEnglish ? province.english : province.name) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + .frame(width: DS.Space.s16 * 2, alignment: .leading) + ForEach(ChinaCarrier.allCases, id: \.self) { carrier in + LatencyValue(speedTest.chinaLatency["\(province.code)-\(carrier.rawValue)"]) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(.vertical, DS.Space.s1 / 2) + } + } + } + } + } +} + +/// 延迟数值:按快慢着色,连不上显示横杠 +private struct LatencyValue: View { + let result: LatencyResult? + + init(_ result: LatencyResult?) { + self.result = result + } + + var body: some View { + Text(verbatim: SpeedText.milliseconds(result?.best)) + .dsFont(.xs, weight: .medium) + .monospacedDigit() + .foregroundStyle(color) + } + + private var color: Color { + guard let best = result?.best else { return DS.Palette.textTertiary } + if best < 80 { return DS.Palette.success } + if best < 200 { return DS.Palette.warning } + return DS.Palette.error + } +} + +// MARK: - 全球节点 + +private struct GlobalNodeCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let speedTest = model.speedTest + SpeedSection(icon: "globe", title: tr("全球节点"), + subtitle: speedTest.isTestingGlobal + ? tr("正在测 \(speedTest.globalProgress)/\(GlobalNode.all.count)") + : SpeedText.checkedAt(speedTest.globalDate)) { + RunButton(isRunning: speedTest.isTestingGlobal, title: tr("测延迟")) { speedTest.runGlobal() } + } content: { + ForEach(GlobalRegion.allCases, id: \.self) { region in + let nodes = GlobalNode.all.filter { $0.region == region } + if !nodes.isEmpty { + Text(L10n.isEnglish ? region.nameEnglish : region.name) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textTertiary) + .padding(.top, DS.Space.s1) + VStack(spacing: 0) { + ForEach(nodes) { node in + GlobalNodeRow(node: node) + if node.id != nodes.last?.id { HairlineDivider() } + } + } + } + } + } + } +} + +private struct GlobalNodeRow: View { + @Environment(AppModel.self) private var model + let node: GlobalNode + + var body: some View { + let speedTest = model.speedTest + let isDownloading = speedTest.downloadingNode == node.id + HStack(spacing: DS.Space.s2) { + FlagImage(countryCode: node.countryCode, height: DS.Space.s4) + VStack(alignment: .leading, spacing: 0) { + Text(L10n.isEnglish ? node.cityEnglish : node.city) + .dsFont(.sm, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + Text(verbatim: node.provider).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + Spacer(minLength: DS.Space.s2) + if isDownloading { + Text(verbatim: SpeedText.rate(speedTest.liveBitsPerSecond > 0 ? speedTest.liveBitsPerSecond : nil)) + .dsFont(.sm, weight: .semibold) + .foregroundStyle(DS.Palette.primary) + .monospacedDigit() + } else if let speed = speedTest.globalSpeed[node.id] { + Text(verbatim: SpeedText.rate(speed.bitsPerSecond)) + .dsFont(.sm, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + .monospacedDigit() + } + LatencyValue(speedTest.globalLatency[node.id]) + .frame(width: DS.Space.s16, alignment: .trailing) + Button { speedTest.download(node) } label: { + Image(systemName: isDownloading ? "stop.fill" : "arrow.down.circle") + } + .buttonStyle(DSButtonStyle(kind: .ghost)) + .help(isDownloading ? tr("停止") : tr("从这个节点下载测速,上限 \(SpeedText.budget(model.settings.speedTestBudget))")) + } + .padding(.vertical, DS.Space.s1) + } +} + +// MARK: - 全球探针 + +private struct GlobalpingCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var speedTest = model.speedTest + SpeedSection(icon: "dot.radiowaves.left.and.right", title: tr("全球探针看目标"), + subtitle: SpeedText.quota(speedTest.globalpingRun)) { + RunButton(isRunning: speedTest.isProbing, title: tr("开始探测")) { speedTest.runGlobalping() } + } content: { + HStack(spacing: DS.Space.s2) { + TextField(tr("域名,例如 example.com"), text: $speedTest.globalpingTarget) + .textFieldStyle(.roundedBorder) + .dsFont(.sm) + .onSubmit { speedTest.runGlobalping() } + SegmentedControl(selection: $speedTest.globalpingProbes, options: [(10, "10"), (20, "20"), (30, "30")]) + .frame(width: DS.Space.s16 * 3) + .help(tr("探针数量,一个探针占一次额度")) + } + if let error = speedTest.globalpingError { + Text(error).dsFont(.xs).foregroundStyle(DS.Palette.error) + } + if let run = speedTest.globalpingRun { + HairlineDivider() + VStack(spacing: 0) { + ForEach(run.probes) { probe in + HStack(spacing: DS.Space.s2) { + FlagImage(countryCode: probe.countryCode, height: DS.Space.s4) + Text(verbatim: probe.city).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary) + Text(verbatim: probe.network) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + Spacer(minLength: DS.Space.s2) + if let loss = probe.loss, loss > 0 { + Text(tr("丢包 \(Int(loss))%")).dsFont(.xs).foregroundStyle(DS.Palette.error) + } + Text(verbatim: SpeedText.milliseconds(probe.milliseconds)) + .dsFont(.sm, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .monospacedDigit() + } + .padding(.vertical, DS.Space.s1) + if probe.id != run.probes.last?.id { HairlineDivider() } + } + } + } + } + } +} + +// MARK: - 文案 + +@MainActor +private enum SpeedText { + static func rate(_ bitsPerSecond: Double?) -> String { + guard let bitsPerSecond, bitsPerSecond > 0 else { return "—" } + return Format.bandwidth(bitsPerSecond) + } + + static func milliseconds(_ value: Double?) -> String { + guard let value else { return "—" } + return value < 10 ? String(format: "%.1f ms", value) : "\(Int(value.rounded())) ms" + } + + static func carrier(_ carrier: ChinaCarrier) -> String { + L10n.isEnglish ? carrier.nameEnglish : carrier.name + } + + static func budget(_ budget: SpeedTestBudget) -> String { + let limit = budget.limit + return tr("\(Int(limit.seconds)) 秒·\(limit.bytes / 1_048_576) MB") + } + + static func stage(_ stage: BroadbandTest.Stage?) -> String { + switch stage { + case .latency: tr("正在测延迟") + case .download: tr("正在测下行") + case .upload: tr("正在测上行") + case nil: "" + } + } + + /// 测过之后显示走的是哪个边缘节点、什么时候测的 + 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 } ?? "") + } + + static func checkedAt(_ date: Date?) -> String? { + guard let date else { return nil } + let formatter = DateFormatter() + formatter.locale = L10n.locale + formatter.dateFormat = "HH:mm" + return tr("\(formatter.string(from: date)) 测") + } + + /// 额度按调用方 IP 计算,跑过一次才知道还剩多少 + static func quota(_ run: GlobalpingRun?) -> String? { + guard let run, let remaining = run.remaining, let limit = run.limit else { return nil } + return tr("本机 IP 这一小时还剩 \(remaining)/\(limit) 次探针额度") + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index e5a5e9f..06ad477 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -17,6 +17,7 @@ public final class AppModel { public let cleaner: CleanerController public let maintenance: MaintenanceController public let network: NetworkController + public let speedTest: SpeedTestController public let explainer = ProcessExplainer() public let updates: UpdateController let diagnostics = DiagnosticsExporter() @@ -39,6 +40,7 @@ public final class AppModel { @ObservationIgnored var openSettings: () -> Void = {} @ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in } + @ObservationIgnored var openSpeedTestWindow: () -> Void = {} @ObservationIgnored var quit: () -> Void = {} public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL) { @@ -57,6 +59,7 @@ public final class AppModel { history = HistoryRecorder(settings: settings, databaseURL: historyURL) sync = SyncController(settings: settings) diskTools = DiskToolsController(helper: helper) + speedTest = SpeedTestController(settings: settings) } /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 53dcbde..fb1d41e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -421,6 +421,10 @@ public final class AppSettings { public var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } + /// 网络测速里单个节点的时间与流量上限 + public var speedTestBudget: SpeedTestBudget { + didSet { defaults.set(speedTestBudget.rawValue, forKey: Keys.speedTestBudget) } + } /// 主窗口开着时在程序坞与 ⌘Tab 中显示图标;默认关闭,和其他菜单栏工具一样只在菜单栏运行 public var showDockIcon: Bool { didSet { defaults.set(showDockIcon, forKey: Keys.showDockIcon) } @@ -525,6 +529,7 @@ public final class AppSettings { panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview 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 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:))) @@ -596,6 +601,7 @@ public final class AppSettings { static let panelTab = "panelTab" static let appearance = "appearance" static let showDockIcon = "showDockIcon" + static let speedTestBudget = "speedTestBudget" 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 new file mode 100644 index 0000000..55658e2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SpeedTestController.swift @@ -0,0 +1,278 @@ +import Foundation +import Localization +import Metrics +import Observation + +/// 网络测速:本机宽带、国内分省三网延迟、全球节点、全球探针看目标。 +/// 四块互相独立,各自可以单独跑、单独停;下载类测试有明确的时间与流量上限 +@MainActor +@Observable +public final class SpeedTestController { + // MARK: 本机宽带 + + public private(set) var broadband: BroadbandResult? + public private(set) var broadbandDate: Date? + public private(set) var broadbandStage: BroadbandTest.Stage? + /// 测试进行中的实时速率,bit/s + public private(set) var liveBitsPerSecond: Double = 0 + public var isTestingBroadband: Bool { broadbandStage != nil } + + // MARK: 国内分省三网 + + public private(set) var chinaLatency: [String: LatencyResult] = [:] + public private(set) var chinaDate: Date? + public private(set) var chinaProgress = 0 + public private(set) var isTestingChina = false + + // MARK: 全球节点 + + public private(set) var globalLatency: [String: LatencyResult] = [:] + public private(set) var globalSpeed: [String: SpeedResult] = [:] + public private(set) var globalDate: Date? + public private(set) var globalProgress = 0 + public private(set) var isTestingGlobal = false + /// 正在做下载测速的节点 + public private(set) var downloadingNode: String? + + // MARK: 全球探针 + + /// 要探测的域名与探针数量:界面输入框绑在这里 + public var globalpingTarget = "" + public var globalpingProbes = 20 + public private(set) var globalpingRun: GlobalpingRun? + public private(set) var globalpingError: String? + public private(set) var isProbing = false + + /// 本次打开窗口以来用掉的流量 + public private(set) var bytesUsed = 0 + + @ObservationIgnored private var broadbandTask: Task? + @ObservationIgnored private var chinaTask: Task? + @ObservationIgnored private var globalTask: Task? + @ObservationIgnored private var downloadTask: Task? + @ObservationIgnored private var probeTask: Task? + @ObservationIgnored private let settings: AppSettings + + public init(settings: AppSettings) { + self.settings = settings + } + + var limit: SpeedLimit { settings.speedTestBudget.limit } + + /// 关窗时停掉所有还在跑的测试,别让它继续占带宽 + func windowDidClose() { + cancelAll() + } + + public func cancelAll() { + broadbandTask?.cancel() + chinaTask?.cancel() + globalTask?.cancel() + downloadTask?.cancel() + probeTask?.cancel() + broadbandStage = nil + isTestingChina = false + isTestingGlobal = false + downloadingNode = nil + isProbing = false + liveBitsPerSecond = 0 + } + + // MARK: 本机宽带 + + public func runBroadband() { + if isTestingBroadband { + broadbandTask?.cancel() + stopBroadband() + return + } + broadbandStage = .latency + liveBitsPerSecond = 0 + broadbandTask = Task { [limit] in + let result = await BroadbandTest.run(limit: limit) { 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 + broadbandDate = Date() + bytesUsed += result.totalBytes + broadbandStage = nil + liveBitsPerSecond = 0 + } + } + + private func stopBroadband() { + broadbandStage = nil + liveBitsPerSecond = 0 + } + + // MARK: 国内分省三网 + + /// 93 个省级节点只做 TCP 建连计时,不下载数据 + public func runChina() { + if isTestingChina { + chinaTask?.cancel() + isTestingChina = false + return + } + isTestingChina = true + chinaProgress = 0 + 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() + } + chinaDate = Date() + isTestingChina = false + } + } + + /// 三网各自的延迟中位数,用来一眼看出哪张网更顺 + public func chinaMedian(_ carrier: ChinaCarrier) -> Double? { + let values = ChinaNode.all.filter { $0.carrier == carrier } + .compactMap { chinaLatency[$0.id]?.best } + .sorted() + guard !values.isEmpty else { return nil } + return values[values.count / 2] + } + + public func chinaReachable(_ carrier: ChinaCarrier) -> (reachable: Int, total: Int) { + let nodes = ChinaNode.all.filter { $0.carrier == carrier } + let measured = nodes.compactMap { chinaLatency[$0.id] } + return (measured.filter(\.isReachable).count, nodes.count) + } + + // MARK: 全球节点 + + public func runGlobal() { + if isTestingGlobal { + globalTask?.cancel() + isTestingGlobal = false + return + } + 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() + } + globalDate = Date() + isTestingGlobal = false + } + } + + /// 单个节点的下载测速,手动触发,一次只跑一个 + public func download(_ node: GlobalNode) { + if downloadingNode == node.id { + downloadTask?.cancel() + downloadingNode = nil + liveBitsPerSecond = 0 + return + } + downloadTask?.cancel() + downloadingNode = node.id + liveBitsPerSecond = 0 + downloadTask = Task { [limit] in + let result = await ThroughputProbe.download(node.downloadURL, limit: limit) { speed, _ in + Task { @MainActor [weak self] in self?.liveBitsPerSecond = speed } + } + guard !Task.isCancelled else { + downloadingNode = nil + liveBitsPerSecond = 0 + return + } + if let result { + globalSpeed[node.id] = result + bytesUsed += result.bytes + } + downloadingNode = nil + liveBitsPerSecond = 0 + } + } + + // MARK: 全球探针 + + /// 匿名调用 Globalping,额度按这台 Mac 的 IP 计算 + public func runGlobalping() { + let (target, probes) = (globalpingTarget, globalpingProbes) + if isProbing { + probeTask?.cancel() + isProbing = false + return + } + isProbing = true + globalpingError = nil + probeTask = Task { + do { + let run = try await GlobalpingClient.ping(target: target, probes: probes) + guard !Task.isCancelled else { + isProbing = false + return + } + globalpingRun = run + } catch let failure as GlobalpingClient.Failure { + globalpingError = Self.message(for: failure) + } catch { + globalpingError = Task.isCancelled ? nil : error.localizedDescription + } + isProbing = false + } + } + + private static func message(for failure: GlobalpingClient.Failure) -> String { + switch failure { + case .invalidTarget: tr("请填写一个域名,例如 example.com") + case .rateLimited(let seconds): + seconds.map { tr("这个小时的免费额度用完了,\($0 / 60 + 1) 分钟后可以再试") } + ?? tr("这个小时的免费额度用完了,过一会儿再试") + case .timeout: tr("探针一直没有返回结果,稍后再试") + case .http(let code): tr("Globalping 返回了错误(HTTP \(code))") + } + } + + /// 同时进行的建连数:太多会互相挤占,延迟数字会偏大 + private static let concurrency = 12 +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift new file mode 100644 index 0000000..5454b5f --- /dev/null +++ b/Packages/OpenStatsKit/Tests/MetricsTests/SpeedTestTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import Metrics + +@Suite("网络测速") +struct SpeedTestTests { + @Test func 国内节点覆盖三十一个省的三网() { + #expect(ChinaNode.provinces.count == 31) + #expect(ChinaNode.all.count == 93) + let bjTelecom = ChinaNode.all.first { $0.code == "bj" && $0.carrier == .telecom } + #expect(bjTelecom?.host == "bj-ct-v4.ip.zstaticcdn.com") + #expect(bjTelecom?.port == 80) + // 每个省三家运营商各一个,编号不重复 + #expect(Set(ChinaNode.all.map(\.id)).count == ChinaNode.all.count) + } + + @Test func 全球节点各大区都有且地址唯一() { + #expect(GlobalNode.all.count == 24) + #expect(Set(GlobalNode.all.map(\.id)).count == GlobalNode.all.count) + #expect(Set(GlobalNode.all.map(\.downloadURL)).count == GlobalNode.all.count) + for region in GlobalRegion.allCases { + #expect(GlobalNode.all.contains { $0.region == region }) + } + // 测速文件都走 HTTPS,主机名与端口取自地址 + #expect(GlobalNode.all.allSatisfy { $0.downloadURL.scheme == "https" && !$0.host.isEmpty && $0.port == 443 }) + } + + @Test func 探针按大洲分配且总数正好() { + for probes in [10, 20, 30] { + let locations = GlobalpingClient.distribution(probes) + #expect(locations.reduce(0) { $0 + $1.limit } == probes) + #expect(locations.allSatisfy { $0.limit > 0 }) + // 亚洲、欧洲、北美一定有份 + #expect(Set(locations.map(\.continent)).isSuperset(of: ["AS", "EU", "NA"])) + } + } + + @Test func 用量上限按档位换算() { + #expect(SpeedTestBudget.full.limit.seconds == 10) + #expect(SpeedTestBudget.full.limit.bytes == 200 * 1024 * 1024) + #expect(SpeedTestBudget.light.limit.bytes < SpeedTestBudget.medium.limit.bytes) + } + + @Test func 带宽按一千进位() { + #expect(Format.bandwidth(820_000) == "820 kbps") + #expect(Format.bandwidth(95_300_000) == "95.3 Mbps") + #expect(Format.bandwidth(1_210_000_000) == "1.21 Gbps") + #expect(Format.bandwidth(0) == "0 bps") + } +} diff --git a/README.md b/README.md index 1f8d4da..0c62d98 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,23 @@ system language by default; switch it in Settings → General → Language. -Latest release **0.4.0** (2026-09-16) · **1** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.4.0** (2026-09-16) · **2** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
+
2026-09-18 · Unreleased · 1 added + +**Added** + +- 网络测速:网络页顶栏新增“网络测速”按钮,打开一个独立窗口,四块内容各自独立、可以单独跑也可以随时停。 + - 本机宽带:用 speed.cloudflare.com 测下行、上行、空载延迟与抖动,并显示这次走的是哪个 Cloudflare 边缘节点。 + - 国内分省三网延迟:31 个省份 × 电信 / 联通 / 移动共 93 个节点,只做 TCP 建连计时、不下载任何数据,给出三网各自的中位延迟与逐省数值。节点来自 zstaticcdn.com。 + - 全球节点:亚太、欧洲、北美等 24 个节点先测延迟,再对想看的节点单独测下载速度,测速文件来自 Linode、Vultr、DataPacket。 + - 全球探针看目标:填一个域名,从全球 10 / 20 / 30 个社区探针 ping 它,看各地访问的延迟与丢包。走 Globalping 的公开接口,请求从本机直接发出、不带任何密钥,用的是本机 IP 每小时的免费额度。 + - 下载类测试有明确的用量上限,顶栏可选 3 秒 / 20 MB、5 秒 / 50 MB、10 秒 / 200 MB(默认),时间与流量哪个先到就停在哪,顶栏实时显示本次用掉多少流量,关窗即停。 + + + +
2026-09-17 · Unreleased · 1 changed **Changed** @@ -97,16 +111,6 @@ Latest release **0.4.0** (2026-09-16) · **1** changes in development · [full c
-
-2026-09-16 · 0.3.2 · 2 fixed - -**Fixed** - -- 修复网络详情里 IP 纯净度查不到的问题:之前一直显示“正在查询”、IP 地址区块只剩国家,因为 cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。 -- 修复查询失败后纯净度长时间空着:空结果不再被当成有效结果保存 7 天,没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。 - -
- ## Activity diff --git a/README.zh-CN.md b/README.zh-CN.md index 3086b52..cb16a41 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,9 +55,23 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.4.0**(2026-09-16) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.4.0**(2026-09-16) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
+2026-09-18 · 未发布 · 新增 1 + +**新增** + +- 网络测速:网络页顶栏新增“网络测速”按钮,打开一个独立窗口,四块内容各自独立、可以单独跑也可以随时停。 + - 本机宽带:用 speed.cloudflare.com 测下行、上行、空载延迟与抖动,并显示这次走的是哪个 Cloudflare 边缘节点。 + - 国内分省三网延迟:31 个省份 × 电信 / 联通 / 移动共 93 个节点,只做 TCP 建连计时、不下载任何数据,给出三网各自的中位延迟与逐省数值。节点来自 zstaticcdn.com。 + - 全球节点:亚太、欧洲、北美等 24 个节点先测延迟,再对想看的节点单独测下载速度,测速文件来自 Linode、Vultr、DataPacket。 + - 全球探针看目标:填一个域名,从全球 10 / 20 / 30 个社区探针 ping 它,看各地访问的延迟与丢包。走 Globalping 的公开接口,请求从本机直接发出、不带任何密钥,用的是本机 IP 每小时的免费额度。 + - 下载类测试有明确的用量上限,顶栏可选 3 秒 / 20 MB、5 秒 / 50 MB、10 秒 / 200 MB(默认),时间与流量哪个先到就停在哪,顶栏实时显示本次用掉多少流量,关窗即停。 + +
+ +
2026-09-17 · 未发布 · 调整 1 **调整** @@ -93,16 +107,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
-
-2026-09-16 · 0.3.2 · 修复 2 - -**修复** - -- 修复网络详情里 IP 纯净度查不到的问题:之前一直显示“正在查询”、IP 地址区块只剩国家,因为 cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。 -- 修复查询失败后纯净度长时间空着:空结果不再被当成有效结果保存 7 天,没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。 - -
- ## 活跃度