新增网络测速窗口:本机宽带、国内分省三网延迟、全球节点下载测速与全球探针

This commit is contained in:
gentpan committed 2026-09-18 00:29:09 +05:00
1 parent 823a3cbe3f
commit 3b901e18cb
19 files changed
+1760 -27

No files matched your search

+3 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img"> <svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
<title>68 commits in the last 26 weeks</title> <title>70 commits in the last 26 weeks</title>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e"> <g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
<text x="58" y="12">Apr</text> <text x="58" y="12">Apr</text>
<text x="114" y="12">May</text> <text x="114" y="12">May</text>
@@ -190,8 +190,9 @@
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2 commits on 2026-09-14</title></rect> <rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2 commits on 2026-09-14</title></rect>
<rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>12 commits on 2026-09-15</title></rect> <rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>12 commits on 2026-09-15</title></rect>
<rect x="380" y="60" width="11" height="11" rx="2" fill="#40c463"><title>12 commits on 2026-09-16</title></rect> <rect x="380" y="60" width="11" height="11" rx="2" fill="#40c463"><title>12 commits on 2026-09-16</title></rect>
<rect x="380" y="74" width="11" height="11" rx="2" fill="#9be9a8"><title>2 commits on 2026-09-17</title></rect>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e"> <g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
<text x="30" y="146">68 commits in the last 26 weeks · through 2026-09-16</text> <text x="30" y="146">70 commits in the last 26 weeks · through 2026-09-17</text>
<text x="286" y="130" text-anchor="end">Less</text> <text x="286" y="130" text-anchor="end">Less</text>
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/> <rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/> <rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+3 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img"> <svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
<title>近 26 周共 68 次提交</title> <title>近 26 周共 70 次提交</title>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e"> <g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
<text x="58" y="12">4月</text> <text x="58" y="12">4月</text>
<text x="114" y="12">5月</text> <text x="114" y="12">5月</text>
@@ -190,8 +190,9 @@
<rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2026-09-14:2 次提交</title></rect> <rect x="380" y="32" width="11" height="11" rx="2" fill="#9be9a8"><title>2026-09-14:2 次提交</title></rect>
<rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>2026-09-15:12 次提交</title></rect> <rect x="380" y="46" width="11" height="11" rx="2" fill="#40c463"><title>2026-09-15:12 次提交</title></rect>
<rect x="380" y="60" width="11" height="11" rx="2" fill="#40c463"><title>2026-09-16:12 次提交</title></rect> <rect x="380" y="60" width="11" height="11" rx="2" fill="#40c463"><title>2026-09-16:12 次提交</title></rect>
<rect x="380" y="74" width="11" height="11" rx="2" fill="#9be9a8"><title>2026-09-17:2 次提交</title></rect>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e"> <g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
<text x="30" y="146">近 26 周共 68 次提交 · 截至 2026-09-16</text> <text x="30" y="146">近 26 周共 70 次提交 · 截至 2026-09-17</text>
<text x="286" y="130" text-anchor="end">少</text> <text x="286" y="130" text-anchor="end">少</text>
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/> <rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/> <rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+11
View File
@@ -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 ### 2026-09-17
#### 调整 #### 调整
@@ -56,6 +56,7 @@ DNS 已设置为 {} DNS set to {}
DNS 缓存已刷新 DNS cache flushed DNS 缓存已刷新 DNS cache flushed
DerivedData,下次编译时自动重建 DerivedData; rebuilt on the next build DerivedData,下次编译时自动重建 DerivedData; rebuilt on the next build
GPU 占用 GPU Usage GPU 占用 GPU Usage
Globalping 返回了错误(HTTP {}) Globalping returned an error (HTTP {})
Google(8.8.8.8) Google (8.8.8.8) Google(8.8.8.8) Google (8.8.8.8)
IP 地址 IP Addresses IP 地址 IP Addresses
IP 类型 IP type IP 类型 IP type
@@ -96,6 +97,7 @@ npm 缓存 npm Cache
{} 个传感器 {} sensors {} 个传感器 {} sensors
{} 个应用,系统自带的不列出 {} apps; built-in apps are not listed {} 个应用,系统自带的不列出 {} apps; built-in apps are not listed
{} 个进程 {} processes {} 个进程 {} processes
{} 中位延迟 {} median
{} 分钟 {} min {} 分钟 {} min
{} 可用 {} · 共 {} {} {} free · {} total {} 可用 {} · 共 {} {} {} free · {} total
{} 天 {} d {} 天 {} d
@@ -106,7 +108,9 @@ npm 缓存 npm Cache
{} 核图形处理器 {}-core GPU {} 核图形处理器 {}-core GPU
{} 正在运行 {} is running {} 正在运行 {} is running
{} 正在运行,卸载前需要先退出。 {} is running. Quit it before uninstalling. {} 正在运行,卸载前需要先退出。 {} is running. Quit it before uninstalling.
{} 测 at {}
{} 秒 {}s {} 秒 {}s
{} 秒 / {} MB {}s / {} MB
{} 组 {} groups {} 组 {} groups
{} 缓存 {} Cache {} 缓存 {} Cache
{} 英寸 {}-inch {} 英寸 {}-inch
@@ -115,6 +119,7 @@ npm 缓存 npm Cache
{} 项 · 需要管理员权限,只读 {} items · needs admin, read-only {} 项 · 需要管理员权限,只读 {} items · needs admin, read-only
{} 项失败 {} failed {} 项失败 {} failed
{}%(阈值 {}%) {}% (threshold {}%) {}%(阈值 {}%) {}% (threshold {}%)
{}/{} 个节点连得上 {}/{} nodes reachable
{}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge. {}{}只剩 {}%,记得充电。 {}{} is down to {}%. Time to charge.
{}{}({}) {}{} ({}) {}{}({}) {}{} ({})
{}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically. {}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically.
@@ -143,6 +148,8 @@ npm 缓存 npm Cache
上次检查发现问题 Last check found problems 上次检查发现问题 Last check found problems
上次检查正常 Last check was fine 上次检查正常 Last check was fine
上次检查:{} Last checked: {} 上次检查:{} Last checked: {}
上行 Upload
下行 Download
下载 Download 下载 Download
下载失败:{} Download failed: {} 下载失败:{} Download failed: {}
下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓 .dmg / .pkg / .xip / .iso files in Downloads; moved to Trash 下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓 .dmg / .pkg / .xip / .iso files in Downloads; moved to Trash
@@ -157,6 +164,7 @@ npm 缓存 npm Cache
不限 No limit 不限 No limit
不限时 Indefinitely 不限时 Indefinitely
与 30 秒前持平 Same as 30s ago 与 30 秒前持平 Same as 30s ago
丢包 {}% {}% loss
严重 Critical 严重 Critical
中等负载 Moderate load 中等负载 Moderate load
中等风险 Medium risk 中等风险 Medium risk
@@ -170,6 +178,7 @@ npm 缓存 npm Cache
介质错误 Media errors 介质错误 Media errors
从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here 从左侧选择应用,或把应用拖到这里 Choose an app on the left, or drop one here
从未 Never 从未 Never
从这个节点下载测速,上限 {} Download from this node, up to {}
代理 Proxy 代理 Proxy
以单核满载为 100% 100% = one full core 以单核满载为 100% 100% = one full core
以后再说 Later 以后再说 Later
@@ -213,6 +222,8 @@ npm 缓存 npm Cache
充电中 Charging 充电中 Charging
充电盒 Case 充电盒 Case
全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃) 全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃)
全球探针看目标 Global Probes
全球节点 Global Nodes
全选 Select All 全选 Select All
全部 All 全部 All
全部删除 Delete all 全部删除 Delete all
@@ -271,6 +282,7 @@ npm 缓存 npm Cache
包名是 {} Bundle ID is {} 包名是 {} Bundle ID is {}
包含城市数据 Include City Data 包含城市数据 Include City Data
协议版本 Protocol version 协议版本 Protocol version
单个节点一次测速最多花的时间与流量,先到哪个停哪个 Time and data cap for one node's speed test, whichever comes first
单行 Inline 单行 Inline
单行文字 Inline Text 单行文字 Inline Text
占整机 CPU 的比例:{} 个核心全部跑满为 100% Share of total CPU: all {} cores fully busy is 100% 占整机 CPU 的比例:{} 个核心全部跑满为 100% Share of total CPU: all {} cores fully busy is 100%
@@ -363,6 +375,7 @@ npm 缓存 npm Cache
启用失败:{} Failed to turn on: {} 启用失败:{} Failed to turn on: {}
唤醒 Wakeups 唤醒 Wakeups
团队 {} team {} 团队 {} team {}
国内分省三网延迟 Latency Across China
国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link 国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link
图形 Graphics 图形 Graphics
图形处理器 GPU 图形处理器 GPU
@@ -379,6 +392,7 @@ npm 缓存 npm Cache
在菜单栏显示{} Show {} in menu bar 在菜单栏显示{} Show {} in menu bar
在访达中显示 Show in Finder 在访达中显示 Show in Finder
型号 Model 型号 Model
域名,例如 example.com Domain, for example example.com
处理器 Processor 处理器 Processor
处理器核心 CPU cores 处理器核心 CPU cores
备用空间 Available spare 备用空间 Available spare
@@ -481,6 +495,7 @@ npm 缓存 npm Cache
开启 / 关闭“合盖不睡眠”(等同 pmset disablesleep) Turn lid-closed sleep prevention on or off (same as pmset disablesleep) 开启 / 关闭“合盖不睡眠”(等同 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 开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置 Adds the icon to the menu bar; hold ⌘ and drag the icon to move it
开始分析 Analyze 开始分析 Analyze
开始探测 Run
开机以来 Since boot 开机以来 Since boot
开机后上传 Uploaded since boot 开机后上传 Uploaded since boot
开机后下载 Downloaded since boot 开机后下载 Downloaded since boot
@@ -531,6 +546,7 @@ npm 缓存 npm Cache
扫描中 Scanning 扫描中 Scanning
扫描中… Scanning… 扫描中… Scanning…
扫描于 Scanned 扫描于 Scanned
抖动 Jitter
折线历史 Line History 折线历史 Line History
折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute) 折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute)
拷贝 Copy 拷贝 Copy
@@ -549,6 +565,8 @@ npm 缓存 npm Cache
掌托 Palm rest 掌托 Palm rest
排队程度 Run Queue 排队程度 Run Queue
探测目标 Probe Target 探测目标 Probe Target
探针一直没有返回结果,稍后再试 The probes never returned a result; try again later
探针数量,一个探针占一次额度 Number of probes; each probe uses one test from the quota
接口 Interface 接口 Interface
接通电源 Plugged in 接通电源 Plugged in
推出 Eject 推出 Eject
@@ -633,7 +651,10 @@ npm 缓存 npm Cache
本地 IPv4 Local IPv4 本地 IPv4 Local IPv4
本地 IPv6 Local IPv6 本地 IPv6 Local IPv6
本地快照 Local snapshots 本地快照 Local snapshots
本机 IP 这一小时还剩 {}/{} 次探针额度 {}/{} probe tests left this hour for this Mac's IP
本机信息 This Mac 本机信息 This Mac
本机宽带 Your Connection
本次用掉 {} {} used
机型 Model 机型 Model
机型标识符 Model Identifier 机型标识符 Model Identifier
机房 Data center 机房 Data center
@@ -688,6 +709,10 @@ npm 缓存 npm Cache
正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization… 正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization…
正在检查… Checking… 正在检查… Checking…
正在检查更新 Checking for updates 正在检查更新 Checking for updates
正在测 {}/{} Testing {}/{}
正在测上行 Measuring upload
正在测下行 Measuring download
正在测延迟 Measuring latency
正在清理… Cleaning… 正在清理… Cleaning…
正在移除… Removing… 正在移除… Removing…
正在统计… Counting… 正在统计… Counting…
@@ -726,6 +751,9 @@ npm 缓存 npm Cache
流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply. 流量经过 {},系统 DNS 可能由它接管,修改后不一定生效。 Traffic goes through {}, which may take over system DNS, so changes might not apply.
浅色 Light 浅色 Light
浅色菜单栏预览 Light menu bar preview 浅色菜单栏预览 Light menu bar preview
测延迟 Latency
测本机宽带、国内分省三网延迟与全球节点 Test your broadband, per-province latency in China and global nodes
测速 Test
浏览器 Browsers 浏览器 Browsers
深色 Dark 深色 Dark
深色菜单栏预览 Dark menu bar preview 深色菜单栏预览 Dark menu bar preview
@@ -800,6 +828,7 @@ npm 缓存 npm Cache
监控 Monitor 监控 Monitor
目标转速 Target speed 目标转速 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. 相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。 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 确认清理 Clean
确认通知能正常显示 Check that notifications appear 确认通知能正常显示 Check that notifications appear
磁盘 Disk 磁盘 Disk
@@ -847,6 +876,7 @@ npm 缓存 npm Cache
线程 Threads 线程 Threads
线程 {} Threads {} 线程 {} Threads {}
线程数(只能读取自己的进程) Thread count (your processes only) 线程数(只能读取自己的进程) Thread count (your processes only)
经 Cloudflare {} 边缘节点 Via the Cloudflare {} edge
结束 {} 失败:{} Failed to end {}: {} 结束 {} 失败:{} Failed to end {}: {}
结束“{}”? End “{}”? 结束“{}”? End “{}”?
结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked 结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked
@@ -863,6 +893,7 @@ npm 缓存 npm Cache
网络名称 Network name 网络名称 Network name
网络已恢复 Network Restored 网络已恢复 Network Restored
网络断开 Network Down 网络断开 Network Down
网络测速 Network Speed Test
网络类型 Network type 网络类型 Network type
网络详情打开时每秒 ping 一次,通了是绿格、不通是红格,显示最近 60 次 Pings once a second while network details are open: green when reachable, red when not, showing the last 60 网络详情打开时每秒 ping 一次,通了是绿格、不通是红格,显示最近 60 次 Pings once a second while network details are open: green when reachable, red when not, showing the last 60
网络运营方 Network operator 网络运营方 Network operator
@@ -913,6 +944,7 @@ npm 缓存 npm Cache
请从菜单退出 OpenStats Quit OpenStats from its menu 请从菜单退出 OpenStats Quit OpenStats from its menu
请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first. 请先在“系统设置 → Apple 智能与 Siri”中开启 Apple 智能。 Turn on Apple Intelligence in System Settings → Apple Intelligence & Siri first.
请在“系统设置 › 通用 › 登录项”中允许 OpenStats 的后台项目。 Allow OpenStats in System Settings › General › Login Items. 请在“系统设置 › 通用 › 登录项”中允许 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 请打开系统自带的“磁盘工具”,选择启动盘运行“急救”进行修复 Open the built-in Disk Utility, select the startup disk and run First Aid to repair it
读 {} · 写 {} Read {} · Write {} 读 {} · 写 {} Read {} · Write {}
读写最多的应用 Top Disk Activity 读写最多的应用 Top Disk Activity
@@ -955,6 +987,8 @@ npm 缓存 npm Cache
还有 {} 个 {} more 还有 {} 个 {} more
还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量 Not enough records yet. The level is recorded every minute while Battery is in the menu bar or this page is open 还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量 Not enough records yet. The level is recorded every minute while Battery is in the menu bar or this page is open
还能用 {} {} left 还能用 {} {} 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. 这个快捷键已被其他应用或系统占用,请换一个 This shortcut is used by another app or the system. Choose another.
这台 Mac 不提供功耗读数 This Mac doesn't report power 这台 Mac 不提供功耗读数 This Mac doesn't report power
这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence. 这台 Mac 不支持 Apple 智能。 This Mac doesn't support Apple Intelligence.
@@ -74,6 +74,21 @@ public enum Format {
return "\(text) \(units[index])" 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 { public static func percent(_ fraction: Double) -> String {
"\(Int((min(1, max(0, fraction)) * 100).rounded()))%" "\(Int((min(1, max(0, fraction)) * 100).rounded()))%"
} }
@@ -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?
}
}
}
@@ -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"),
]
}
@@ -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..<attempts {
if Task.isCancelled { break }
if let milliseconds = await connect(host: host, port: port, timeout: timeout) {
result.best = min(result.best ?? .greatestFiniteMagnitude, milliseconds)
} else {
result.failures += 1
}
}
return result
}
private static let queue = DispatchQueue(label: "com.openstats.speedtest.tcp", qos: .utility)
private static func connect(host: String, port: UInt16, 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 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<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
}
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..<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)
}
return (samples, headers)
}
}
@@ -9,6 +9,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
private var menuBar: MenuBarController! private var menuBar: MenuBarController!
private var mainWindow: MainWindowController! private var mainWindow: MainWindowController!
private var updateWindow: UpdateWindowController! private var updateWindow: UpdateWindowController!
private var speedTestWindow: SpeedTestWindowController!
private var updateTimer: Timer? private var updateTimer: Timer?
private let hotKeys = HotKeyCenter() private let hotKeys = HotKeyCenter()
private var workspaceObservers: [NSObjectProtocol] = [] private var workspaceObservers: [NSObjectProtocol] = []
@@ -27,6 +28,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
mainWindow = MainWindowController(model: model) mainWindow = MainWindowController(model: model)
mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
updateWindow = UpdateWindowController(model: model) updateWindow = UpdateWindowController(model: model)
speedTestWindow = SpeedTestWindowController(model: model)
speedTestWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
menuBar.update() menuBar.update()
// 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置 // 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置
@@ -38,6 +41,10 @@ public final class AppController: NSObject, NSApplicationDelegate {
self?.menuBar.dismissPopovers() self?.menuBar.dismissPopovers()
self?.mainWindow.show(tab: tab) self?.mainWindow.show(tab: tab)
} }
model.openSpeedTestWindow = { [weak self] in
self?.menuBar.dismissPopovers()
self?.speedTestWindow.show()
}
model.quit = { NSApp.terminate(nil) } model.quit = { NSApp.terminate(nil) }
model.helper.refreshStatus() model.helper.refreshStatus()
Task { await model.helper.verifyVersion() } Task { await model.helper.verifyVersion() }
@@ -77,6 +84,15 @@ public final class AppController: NSObject, NSApplicationDelegate {
if arguments.contains("--show-window") { if arguments.contains("--show-window") {
mainWindow.show(tab: nil) mainWindow.show(tab: nil)
} }
if arguments.contains("--show-speedtest") {
speedTestWindow.show()
}
// 开发调试:--probe <域名> 打开测速窗口并立即用全球探针 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 智能解释该进程 // 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程
if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first { if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first {
mainWindow.show(tab: .processes) mainWindow.show(tab: .processes)
@@ -161,7 +177,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
/// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里 /// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里
private func updateActivationPolicy() { private func updateActivationPolicy() {
let hasWindow = mainWindow.isVisible let hasWindow = mainWindow.isVisible || speedTestWindow.isVisible
let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory
guard NSApp.activationPolicy() != policy else { return } guard NSApp.activationPolicy() != policy else { return }
NSApp.setActivationPolicy(policy) NSApp.setActivationPolicy(policy)
@@ -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)
}
}
@@ -168,6 +168,9 @@ extension DS {
static let panelGap: CGFloat = 4 static let panelGap: CGFloat = 4
/// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt /// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt
static let windowHeader: CGFloat = DS.Space.s12 + DS.Space.s1 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 static let windowDefaultHeight: CGFloat = 720
/// 主窗口默认内容区宽度(侧边栏另计) /// 主窗口默认内容区宽度(侧边栏另计)
@@ -226,6 +226,16 @@ private struct PageHeader: View {
.dsGlass(in: Capsule(), fallback: DS.Palette.primary.opacity(0.12)) .dsGlass(in: Capsule(), fallback: DS.Palette.primary.opacity(0.12))
} }
if tab == .memory { PurgeMemoryButton() } 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 let items = tab.menuBarItems
if !items.isEmpty { if !items.isEmpty {
@@ -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<Trailing: View, Content: View>: 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) 次探针额度")
}
}
@@ -17,6 +17,7 @@ public final class AppModel {
public let cleaner: CleanerController public let cleaner: CleanerController
public let maintenance: MaintenanceController public let maintenance: MaintenanceController
public let network: NetworkController public let network: NetworkController
public let speedTest: SpeedTestController
public let explainer = ProcessExplainer() public let explainer = ProcessExplainer()
public let updates: UpdateController public let updates: UpdateController
let diagnostics = DiagnosticsExporter() let diagnostics = DiagnosticsExporter()
@@ -39,6 +40,7 @@ public final class AppModel {
@ObservationIgnored var openSettings: () -> Void = {} @ObservationIgnored var openSettings: () -> Void = {}
@ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in } @ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in }
@ObservationIgnored var openSpeedTestWindow: () -> Void = {}
@ObservationIgnored var quit: () -> Void = {} @ObservationIgnored var quit: () -> Void = {}
public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL) { public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL) {
@@ -57,6 +59,7 @@ public final class AppModel {
history = HistoryRecorder(settings: settings, databaseURL: historyURL) history = HistoryRecorder(settings: settings, databaseURL: historyURL)
sync = SyncController(settings: settings) sync = SyncController(settings: settings)
diskTools = DiskToolsController(helper: helper) diskTools = DiskToolsController(helper: helper)
speedTest = SpeedTestController(settings: settings)
} }
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
@@ -421,6 +421,10 @@ public final class AppSettings {
public var appearance: AppearanceMode { public var appearance: AppearanceMode {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
} }
/// 网络测速里单个节点的时间与流量上限
public var speedTestBudget: SpeedTestBudget {
didSet { defaults.set(speedTestBudget.rawValue, forKey: Keys.speedTestBudget) }
}
/// 主窗口开着时在程序坞与 ⌘Tab 中显示图标;默认关闭,和其他菜单栏工具一样只在菜单栏运行 /// 主窗口开着时在程序坞与 ⌘Tab 中显示图标;默认关闭,和其他菜单栏工具一样只在菜单栏运行
public var showDockIcon: Bool { public var showDockIcon: Bool {
didSet { defaults.set(showDockIcon, forKey: Keys.showDockIcon) } 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 panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
showDockIcon = defaults.bool(forKey: Keys.showDockIcon) showDockIcon = defaults.bool(forKey: Keys.showDockIcon)
speedTestBudget = defaults.string(forKey: Keys.speedTestBudget).flatMap(SpeedTestBudget.init(rawValue:)) ?? .full
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate
expandedPopoverSections = Set((defaults.stringArray(forKey: Keys.expandedPopoverSections) ?? []).compactMap(PopoverSection.init(rawValue:))) 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 panelTab = "panelTab"
static let appearance = "appearance" static let appearance = "appearance"
static let showDockIcon = "showDockIcon" static let showDockIcon = "showDockIcon"
static let speedTestBudget = "speedTestBudget"
static let cleanPrefersTrash = "cleanPrefersTrash" static let cleanPrefersTrash = "cleanPrefersTrash"
static let menuBarLayout = "menuBarLayout" static let menuBarLayout = "menuBarLayout"
static let hiddenPopoverSections = "hiddenPopoverSections" static let hiddenPopoverSections = "hiddenPopoverSections"
@@ -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<Void, Never>?
@ObservationIgnored private var chinaTask: Task<Void, Never>?
@ObservationIgnored private var globalTask: Task<Void, Never>?
@ObservationIgnored private var downloadTask: Task<Void, Never>?
@ObservationIgnored private var probeTask: Task<Void, Never>?
@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
}
@@ -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")
}
}
+15 -11
View File
@@ -59,9 +59,23 @@ system language by default; switch it in Settings → General → Language.
<!-- changelog:start --> <!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. --> <!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
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)
<details open> <details open>
<summary><b>2026-09-18</b> · Unreleased · 1 added</summary>
**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(默认),时间与流量哪个先到就停在哪,顶栏实时显示本次用掉多少流量,关窗即停。
</details>
<details>
<summary><b>2026-09-17</b> · Unreleased · 1 changed</summary> <summary><b>2026-09-17</b> · Unreleased · 1 changed</summary>
**Changed** **Changed**
@@ -97,16 +111,6 @@ Latest release **0.4.0** (2026-09-16) · **1** changes in development · [full c
</details> </details>
<details>
<summary><b>2026-09-16</b> · 0.3.2 · 2 fixed</summary>
**Fixed**
- 修复网络详情里 IP 纯净度查不到的问题:之前一直显示“正在查询”、IP 地址区块只剩国家,因为 cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。
- 修复查询失败后纯净度长时间空着:空结果不再被当成有效结果保存 7 天,没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。
</details>
<!-- changelog:end --> <!-- changelog:end -->
## Activity ## Activity
+15 -11
View File
@@ -55,9 +55,23 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
<!-- changelog:start --> <!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 --> <!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
最新版本 **0.4.0**(2026-09-16) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) 最新版本 **0.4.0**(2026-09-16) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open> <details open>
<summary><b>2026-09-18</b> · 未发布 · 新增 1</summary>
**新增**
- 网络测速:网络页顶栏新增“网络测速”按钮,打开一个独立窗口,四块内容各自独立、可以单独跑也可以随时停。
- 本机宽带:用 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(默认),时间与流量哪个先到就停在哪,顶栏实时显示本次用掉多少流量,关窗即停。
</details>
<details>
<summary><b>2026-09-17</b> · 未发布 · 调整 1</summary> <summary><b>2026-09-17</b> · 未发布 · 调整 1</summary>
**调整** **调整**
@@ -93,16 +107,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
</details> </details>
<details>
<summary><b>2026-09-16</b> · 0.3.2 · 修复 2</summary>
**修复**
- 修复网络详情里 IP 纯净度查不到的问题:之前一直显示“正在查询”、IP 地址区块只剩国家,因为 cleanip.io 不再接受查询指定 IP,改为只查请求方自己的出口。现在分别锁定 IPv4 与 IPv6 连接各查一次,两族仍然各有自己的归属地与纯净度;锁定地址族的连接建不起来时退回系统默认的请求方式。用了分流代理、cleanip.io 看到的出口与 Cloudflare 看到的不同时,以 cleanip.io 看到的地址显示,纯净度与地址对得上。
- 修复查询失败后纯净度长时间空着:空结果不再被当成有效结果保存 7 天,没查到的下次打开网络详情就会重查,服务恢复后纯净度不会一直空着。
</details>
<!-- changelog:end --> <!-- changelog:end -->
## 活跃度 ## 活跃度