新增网络测速窗口:本机宽带、国内分省三网延迟、全球节点下载测速与全球探针
This commit is contained in:
1 parent
823a3cbe3f
commit
3b901e18cb
19 files changed
+1760
-27
No files matched your search
@@ -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 |
@@ -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 |
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 -->
|
||||||
|
|
||||||
## 活跃度
|
## 活跃度
|
||||||
|
|||||||
Reference in new issue
Block a user