From 80f7639f21856acfba7f698b9802311a33e96190 Mon Sep 17 00:00:00 2001 From: gentpan Date: Fri, 18 Sep 2026 00:57:49 +0500 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=87=BA=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E6=B5=81=E7=AA=97=E5=8F=A3=EF=BC=9A=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=20VPN=20=E4=B8=8E=E4=BB=A3=E7=90=86=E6=98=AF=E5=90=A6=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=8C25=20=E4=B8=AA=E7=BD=91=E7=AB=99=E5=88=86?= =?UTF-8?q?=E5=88=AB=E4=BB=8E=E5=93=AA=E4=B8=AA=E5=87=BA=E5=8F=A3=E5=87=BA?= =?UTF-8?q?=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 + .../Sources/Localization/Translations.swift | 84 +- .../Metrics/AddressFamilyRequest.swift | 27 +- .../Sources/Metrics/EgressAnalysis.swift | 176 ++++ .../Sources/Metrics/EgressProbe.swift | 351 ++++++++ .../Sources/Metrics/ProxyEnvironment.swift | 103 +++ .../Sources/Metrics/PublicAddressLookup.swift | 4 + .../OpenStatsUI/AppKit/AppController.swift | 15 +- .../AppKit/EgressWindowController.swift | 53 ++ .../Sources/OpenStatsUI/Design/Tokens.swift | 7 + .../OpenStatsUI/Panel/EgressWindowView.swift | 771 ++++++++++++++++++ .../OpenStatsUI/Panel/MainWindowView.swift | 10 + .../OpenStatsUI/Popover/NetworkPopover.swift | 47 +- .../Resources/Logos/site-baidu.svg | 1 + .../Resources/Logos/site-bilibili.svg | 1 + .../Resources/Logos/site-claude.svg | 1 + .../Resources/Logos/site-cloudflare.svg | 7 + .../Resources/Logos/site-discord.svg | 1 + .../Resources/Logos/site-docker.svg | 3 + .../Resources/Logos/site-douyin.svg | 1 + .../Resources/Logos/site-gemini.svg | 1 + .../Resources/Logos/site-github.svg | 3 + .../Resources/Logos/site-google.svg | 11 + .../OpenStatsUI/Resources/Logos/site-grok.svg | 4 + .../Resources/Logos/site-instagram.svg | 1 + .../Resources/Logos/site-midjourney.svg | 6 + .../Resources/Logos/site-netflix.svg | 1 + .../OpenStatsUI/Resources/Logos/site-npm.svg | 1 + .../Resources/Logos/site-openai.svg | 1 + .../Resources/Logos/site-perplexity.svg | 1 + .../Resources/Logos/site-reddit.svg | 57 ++ .../Resources/Logos/site-taobao.svg | 1 + .../Resources/Logos/site-telegram.svg | 1 + .../Resources/Logos/site-tiktok.svg | 1 + .../Resources/Logos/site-twitch.svg | 1 + .../Resources/Logos/site-wechat.svg | 1 + .../OpenStatsUI/Resources/Logos/site-x.svg | 1 + .../Resources/Logos/site-youtube.svg | 1 + .../Sources/OpenStatsUI/State/AppModel.swift | 2 + .../OpenStatsUI/State/EgressController.swift | 147 ++++ .../Tests/MetricsTests/EgressTests.swift | 133 +++ README.md | 46 +- README.zh-CN.md | 46 +- project.yml | 2 +- 44 files changed, 2067 insertions(+), 81 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/Metrics/EgressAnalysis.swift create mode 100644 Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift create mode 100644 Packages/OpenStatsKit/Sources/Metrics/ProxyEnvironment.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/EgressWindowController.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/EgressWindowView.swift create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-baidu.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-bilibili.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-claude.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-cloudflare.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-discord.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-docker.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-douyin.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-gemini.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-github.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-google.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-grok.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-instagram.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-midjourney.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-netflix.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-npm.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-openai.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-perplexity.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-reddit.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-taobao.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-telegram.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-tiktok.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-twitch.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-wechat.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-x.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-youtube.svg create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/State/EgressController.swift create mode 100644 Packages/OpenStatsKit/Tests/MetricsTests/EgressTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6818cec..f38ff05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-09-18 + +#### 新增 + +- 新增出口与分流窗口:网络页顶栏和菜单栏网络弹窗各有一个入口。一次检测约十秒,看清 VPN 与代理有没有生效、各个网站分别从哪个出口出去。 +- 一句话结论:分“未使用代理”“代理出口(地区)”“代理没有生效”“网络不通”四种情况,右侧给出经代理、直连、连不上的网站数量。 +- 本机网络:显示物理网卡的出口 IP、归属地、运营商与 IPv6,归属地数据来自 cleanip.io。 +- 网站分流:ChatGPT、Claude、YouTube、GitHub、哔哩哔哩等 25 个常用网站,按 AI、影音、社交、开发、国内分类筛选。接入 Cloudflare 的网站能实测出口与边缘节点,其余按国内 / 国际规则推断,每个网站带延迟信号条。 +- 代理方式:列出 VPN / 隧道、系统代理、自动代理配置、DNS(被代理接管时会标出)与检测到的代理软件。 + +#### 样式 + +- 菜单栏网络弹窗的 DNS 区块重排:网络服务与线路做成两个徽章(Wi-Fi / 有线、直连或经过哪个代理),走隧道时标题旁有叹号提示 DNS 可能被接管,刷新 DNS 缓存改为图标按钮。 ## 0.5.0 · 2026-09-18 ### 2026-09-18 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index aeeac1b..db1efb6 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -49,6 +49,7 @@ CPU 负载偏高 CPU load is high CPU 负载很高 CPU load is very high CPU 过热 CPU Overheating CPU:{}(以单核满载为 100%) CPU: {} (100% = one full core) +Cloudflare 接入节点(机场代码) Cloudflare edge location (airport code) Cloudflare(1.1.1.1) Cloudflare (1.1.1.1) CoreSimulator 的动态库与运行时缓存 CoreSimulator dyld and runtime caches DNS 地址格式不正确 Invalid DNS address @@ -61,6 +62,8 @@ Google(8.8.8.8) Google (8.8.8.8) IP 地址 IP Addresses IP 类型 IP type IP 纯净度 IP cleanliness +IPv6 没有经过代理:只走 IPv6 的连接会直接暴露本机地址 {}。 IPv6 isn't going through the proxy: IPv6-only connections expose your address {}. +IPv6 连接也经过代理。 IPv6 connections also go through the proxy. OpenStats {} 已发布 OpenStats {} is available OpenStats 控制 Controlled by OpenStats OpenStats 控制中 Controlled by OpenStats @@ -82,6 +85,9 @@ SMC 通信与 Apple Silicon 风扇解锁流程移植自该项目 · MIT License SSD 健康 SSD Health Time Machine 在备份之间会先在本机留下快照,它们占的空间计入“可清除”,系统缺空间时会自动删。手动删除不影响已完成的备份。 Between backups Time Machine keeps snapshots on this Mac. Their space counts as purgeable and macOS removes them when it runs low. Deleting them by hand does not affect completed backups. VPN / 代理 VPN / Proxy +VPN / 隧道 VPN / Tunnel +VPN 不允许绕开它连接 The VPN doesn't allow bypassing it +VPN 不允许绕开它连接,读不到本机网络原本的出口。下方按出口列出各网站。 The VPN doesn't allow connections that bypass it, so your network's original exit can't be read. Sites are listed by exit below. VPN 隧道 VPN Tunnel Xcode 归档 Xcode Archives Xcode 编译缓存 Xcode Build Cache @@ -96,6 +102,7 @@ npm 缓存 npm Cache {} 个 {} {} 个传感器 {} sensors {} 个应用,系统自带的不列出 {} apps; built-in apps are not listed +{} 个网站 {} sites {} 个进程 {} processes {} 中位延迟 {} median {} 分钟 {} min @@ -111,6 +118,7 @@ npm 缓存 npm Cache {} 测 at {} {} 秒 {}s {} 秒 / {} MB {}s / {} MB +{} 秒·{} MB {}s · {} MB {} 组 {} groups {} 缓存 {} Cache {} 英寸 {}-inch @@ -125,15 +133,19 @@ npm 缓存 npm Cache {}。系统会自动分配任务,不用你操心 {}. macOS assigns work between them automatically. {}中 {} {}前 {} ago +{}前检测 Checked {} ago {}后充满 Full in {} {}电量低 {} battery low {}({}) {} ({}) +{}(代理接管) {} (handled by proxy) {},{},健康 {} {}, {}, health {} {},后台{} {}, background {} {}:{} {}: {} +{}:{}/{} 个节点连得上 {}: {}/{} nodes reachable ~/.npm/_cacache,安装依赖时自动重新下载 ~/.npm/_cacache; downloaded again when installing dependencies ~/Library/Caches 中各应用的缓存,删除后会按需重建 App caches in ~/Library/Caches; rebuilt as needed ~/Library/Logs 中的日志和诊断报告 Logs and diagnostic reports in ~/Library/Logs +· 结果反映 OpenStats 自己发出的请求,按应用分流的规则下其他应用可能走不同出口 · Results reflect requests sent by OpenStats; with per-app rules, other apps may use different exits “{}”只剩 {} 可用。可以用 OpenStats 的清理功能释放缓存。 “{}” has only {} free. Use OpenStats Cleanup to clear caches. “{}”({})会移到废纸篓,可以从废纸篓放回。 “{1}” ({2}) will be moved to the Trash; you can put it back from there. 、 , @@ -157,15 +169,18 @@ npm 缓存 npm Cache 不在允许清理的目录内 Not inside an allowed cleanup folder 不在可卸载的位置:{} Not in a location that can be uninstalled: {} 不提供电量 No battery info +不支持 IPv6 IPv6 not supported 不是团队 {} 签名的完整应用({}) Not a complete app signed by team {} ({}) 不是应用程序 Not an application 不是绝对路径 Not an absolute path +不经过代理 Without proxy 不能删除清理目录本身 Can't delete the cleanup folder itself 不限 No limit 不限时 Indefinitely 与 30 秒前持平 Same as 30s ago 丢包 {}% {}% loss 严重 Critical +中位延迟 Median 中等负载 Moderate load 中等风险 Medium risk 中继 Relay @@ -180,6 +195,12 @@ npm 缓存 npm Cache 从未 Never 从这个节点下载测速,上限 {} Download from this node, up to {} 代理 Proxy +代理出口 proxy exit +代理出口不支持 IPv6,IPv6 连接也不会绕过代理泄露地址。 The proxy exit doesn't support IPv6, and IPv6 connections don't leak your address around the proxy. +代理出口({}) proxy exit ({}) +代理方式 Proxy Setup +代理没有生效 Proxy Not Working +代理软件 Proxy Apps 以单核满载为 100% 100% = one full core 以后再说 Later 以系统权限运行的后台服务,只接受本应用的请求 A background service with system privileges that only accepts requests from this app @@ -221,12 +242,14 @@ npm 缓存 npm Cache 充电上限可在系统设置中设为 80%–100%,长期接电源时有助于延缓电池老化 Set a charge limit of 80%–100% in System Settings to slow battery aging when you stay plugged in 充电中 Charging 充电盒 Case +全局代理 Global Proxy 全局快捷键(在任何应用中都能使用,需要包含 ⌘、⌥ 或 ⌃) Global shortcuts (work in any app; must include ⌘, ⌥ or ⌃) 全球探针看目标 Global Probes 全球节点 Global Nodes 全选 Select All 全部 All 全部删除 Delete all +全部经代理 All Traffic Proxied 公网 IP Public IP 公网 IP 查询 Public IP Lookup 公网 IPv4 Public IPv4 @@ -255,10 +278,18 @@ npm 缓存 npm Cache 内存类型 Memory type 内存:{} Memory: {} 写入 Write +出口 Exit +出口与分流 Egress & Routing +出口与分流:检查 VPN 与代理是否生效、各网站从哪个出口出去 Egress & Routing: check whether your VPN and proxy work and which exit each site uses +出口依据 Basis +出口未知 Exit unknown 出现介质错误,建议备份 Media errors found; back up your data +分别经物理网卡、系统代理与 VPN 隧道访问检测目标,约需 10 秒。 Reaching test targets through the network interface, the system proxy, and the VPN tunnel. This takes about 10 seconds. +分流正常 Split Routing Works 切换到浅色 Switch to Light 切换到深色 Switch to Dark 刚刚 Just now +刚刚检测 Checked just now 刚刚被使用 Recently used 删除 Delete 删除… Delete… @@ -327,6 +358,7 @@ npm 缓存 npm Cache 受保护 Protected 受保护的项目:{} Protected item: {} 另一台 Mac Another Mac +另有 {} 个出口,见网站分流 {} more exits, see Site Routing 另有 {} 移到废纸篓 Plus {} moved to Trash 另有 {} 项 {} more 只含当前用户的进程 Your processes only @@ -336,6 +368,7 @@ npm 缓存 npm Cache 只在菜单栏显示电池或打开电池页面时记录 Recorded only while Battery is in the menu bar or the Battery page is open 只显示菜单栏时的采样间隔;打开弹窗或主窗口时为 1 秒(进程页 2 秒) Sampling interval when only the menu bar is shown; 1 second with a popover or window open (2 seconds on Processes) 只查找以该应用包名命名的文件,以及 Application Support、Logs 下与应用同名的目录;钥匙串与其他应用共享的数据不会动。程序坞里的图标会一并移除 Only files named after the app's bundle ID, plus folders with the app's name in Application Support and Logs, are included. Keychains and data shared with other apps are never touched. The Dock icon is removed as well +只设置了系统代理:浏览器等应用走代理,命令行工具、游戏等不读代理设置的程序仍在直连。要接管全部流量,可以开启增强模式或 TUN 模式。 Only a system proxy is set: browsers and most apps use it, but command-line tools, games, and other programs that ignore proxy settings still connect directly. To route all traffic, turn on enhanced mode or TUN mode. 可以恢复,但清空废纸篓前不会释放空间 Recoverable, but space isn't freed until the Trash is emptied 可执行文件:{} Executable: {} 可清理 · 已选 {} 项 Cleanable · {} selected @@ -346,6 +379,7 @@ npm 缓存 npm Cache 可用 {} / 共 {} {1} free of {2} 可用内存只剩 {},系统正在压缩和交换内存。关闭不用的应用可以缓解。 Only {} of memory is available and the system is compressing and swapping. Quitting unused apps will help. 可用空间包含系统可以随时清除的缓存,与访达显示一致 Available space includes purgeable caches, matching Finder +可访问 Reachable 右侧风扇 Right Fan 右耳 Right 各核心占用 Per-Core Load @@ -373,9 +407,14 @@ npm 缓存 npm Cache 启用 Turn On 启用 {} Enable {} 启用失败:{} Failed to turn on: {} +哔哩哔哩 Bilibili 唤醒 Wakeups 团队 {} team {} +国内 China 国内分省三网延迟 Latency Across China +国内网站也绕到了{},访问会变慢,也可能触发风控。 Chinese sites also detour through the {}, which is slower and may trigger security checks. +国内网站直连,国际网站经 {} 的{}。 Chinese sites connect directly; international sites go through {}'s {}. +国内网站走了代理,访问会绕路变慢。 Chinese sites are going through the proxy, which takes a longer route and is slower. 国内网络建议选阿里云或腾讯;选路由器只检测本地连接 In mainland China, Alibaba Cloud or Tencent work best; Router only checks the local link 图形 Graphics 图形处理器 GPU @@ -412,6 +451,7 @@ npm 缓存 npm Cache 安静 Quiet 定时 ping 一个地址,记录网络是否通畅 Pings an address regularly to record whether the network is reachable 定时探测网络 Connectivity Probe +实测 Measured 家目录 Home folder 容量 Capacity 导出… Export… @@ -485,8 +525,10 @@ npm 缓存 npm Cache 应用缓存 App Caches 应用退出或断开连接时,自动恢复风扇与睡眠设置 Restores fan and sleep settings when the app quits or disconnects 废纸篓 Trash +延迟 Latency 开 On 开关防休眠 Toggle Keep Awake +开发 Developer 开发构建不支持在线升级 Development builds can't update online 开发构建不支持在线升级,请下载安装包 Development builds can't update online; download the installer instead 开发版 Development @@ -506,6 +548,7 @@ npm 缓存 npm Cache 强制刷新:忽略缓存,立即重新查询公网 IP 与归属地 Force refresh: ignore the cache and look up the public IP and location again now 强制退出 Force Quit 归属地 Location +归属地数据来自 Location data from 当前 {} · 发布于 {} · {} · {} Current {} · Released {} · {} · {} 当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。 This is an ad-hoc signed development build, so the helper can only check the bundle ID. Team verification turns on automatically with a Developer ID build. 当前效果(实时数据) Preview (live data) @@ -514,8 +557,10 @@ npm 缓存 npm Cache 当前版本 {} · {} · {} Current version {} · {} · {} 当前用户 Current User 录制快捷键 Record Shortcut +影音 Video 循环 {} 次 {} cycles 循环次数 Cycle count +微信 WeChat 忙 Busy 快捷开关 Quick Toggles 快照名称格式不认识,没有删除 Unrecognized snapshot names; nothing was deleted @@ -527,6 +572,7 @@ npm 缓存 npm Cache 所属应用:{}({}) App: {} ({}) 所有内容先移到废纸篓。 Everything goes to the Trash first. 所有指标合成一个图标,点击打开主窗口 All metrics in one icon; click to open the main window +所有检测目标都连不上。请检查网络连接,或者代理软件是否正常运行。 None of the test targets could be reached. Check your network connection and whether your proxy app is running properly. 所有用户 All Users 所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理 All-users and system items need admin rights; manage them in Login Items in System Settings 所有磁盘合计 All disks @@ -547,6 +593,7 @@ npm 缓存 npm Cache 扫描中… Scanning… 扫描于 Scanned 抖动 Jitter +抖音 Douyin 折线历史 Line History 折线是最近 30 次采样(约 1 分钟)的走势 The line shows the last 30 samples (about a minute) 拷贝 Copy @@ -558,7 +605,9 @@ npm 缓存 npm Cache 按应用 By App 按应用汇总 By App 按文件夹 By folder +按系统代理访问国际网站 International sites via system proxy 按系统休眠 System sleep +按规则分流 Rule-Based Routing 按进程 By Process 换一台 Mac 登录后,菜单栏、外观、通知与快捷键等偏好会自动恢复。不登录也能使用全部功能。 Sign in on another Mac and your menu bar, appearance, notification and hotkey preferences come back automatically. Everything works without signing in. 换入 / 换出 Swap in / out @@ -567,10 +616,12 @@ npm 缓存 npm Cache 探测目标 Probe Target 探针一直没有返回结果,稍后再试 The probes never returned a result; try again later 探针数量,一个探针占一次额度 Number of probes; each probe uses one test from the quota +接入 Cloudflare 的网站能实测出口,其余按国内 / 国际规则推断 Exits are measured for sites behind Cloudflare and inferred for the rest 接口 Interface 接通电源 Plugged in 推出 Eject 推出中… Ejecting… +推断 Inferred 搜索名称、PID 或用户 Search name, PID or user 搜索应用 Search apps 撤销 Undo @@ -597,6 +648,7 @@ npm 缓存 npm Cache 无法打开 Couldn't open 无法打开登录窗口 Couldn't open the sign-in window 无法推出“{}”:{} Couldn't eject “{1}”: {2} +无法确认是否经过代理 Can't Confirm Proxy Use 无法移到废纸篓:{} Couldn't move to the Trash: {} 无法读取 Unreadable 无法读取签名 Couldn't read the signature @@ -635,7 +687,9 @@ npm 缓存 npm Cache 服务器返回 {} Server returned {} 服务器返回的数据格式不正确 The server returned malformed data 未使用 Unused +未使用代理 No Proxy in Use 未保存的内容可能会丢失。强制退出会立即结束,不给应用保存的机会。 Unsaved work may be lost. Force Quit ends it immediately without letting it save. +未发现 None found 未安装 Not Installed 未完成的下载 Incomplete Downloads 未开启,Mac 按系统设置休眠 Off; your Mac sleeps as usual @@ -646,6 +700,7 @@ npm 缓存 npm Cache 未知状态 Unknown status 未签名或临时签名 Unsigned or ad-hoc signed 未签名(开发构建) Unsigned (development build) +未设置 Not set 未连接 Not connected 未连接网络 No network 本地 IPv4 Local IPv4 @@ -654,6 +709,7 @@ npm 缓存 npm Cache 本机 IP 这一小时还剩 {}/{} 次探针额度 {}/{} probe tests left this hour for this Mac's IP 本机信息 This Mac 本机宽带 Your Connection +本机网络 Your Network 本次用掉 {} {} used 机型 Model 机型标识符 Model Identifier @@ -682,19 +738,21 @@ npm 缓存 npm Cache 核心热力图 Core Heatmap 核心负载 Core Load 检查 Check +检查 VPN 与代理是否生效、各网站从哪个出口出去 Check whether your VPN and proxy work and which exit each site uses 检查中… Checking… 检查完成,但没有得到明确结论 The check finished without a clear verdict 检查更新 Check for Updates 检查更新… Check for Updates… 检查更新失败 Update check failed 检查未能完成(退出码 {}) The check didn't complete (exit code {}) +检测中 {}/{} Checking {}/{} +检测到 {},但测试的网站都在直连,没有经过代理出口。 {} was detected, but all tested sites connect directly without going through a proxy exit. 模式 Mode 模拟器 Simulators 模拟器缓存 Simulator Caches 正在下载 Downloading 正在使用 In use 正在修改 DNS… Changing DNS… -正在刷新… Flushing… 正在同步 Syncing 正在同步… Syncing… 正在安装,完成后 OpenStats 会自动重启。 Installing. OpenStats will relaunch when it's done. @@ -709,6 +767,7 @@ npm 缓存 npm Cache 正在核对校验值、开发者签名与 Apple 公证… Verifying checksum, developer signature and Apple notarization… 正在检查… Checking… 正在检查更新 Checking for updates +正在检测出口与分流… Checking egress and routing… 正在测 {}/{} Testing {}/{} 正在测上行 Measuring upload 正在测下行 Measuring download @@ -744,6 +803,7 @@ npm 缓存 npm Cache 没有匹配的进程 No matching processes 没有启动项 No startup items 没有已连接的蓝牙设备 No connected Bluetooth devices +没有检测到 VPN 或系统代理,所有流量都从本机网络直接出去。 No VPN or system proxy detected. All traffic leaves directly from your network. 没有检测到显示器 No displays detected 没有移动任何文件{} Nothing was moved{} 没有超过 50 MB 的文件 No files over 50 MB @@ -753,8 +813,10 @@ npm 缓存 npm Cache 浅色菜单栏预览 Light menu bar preview 测延迟 Latency 测本机宽带、国内分省三网延迟与全球节点 Test your broadband, per-province latency in China and global nodes +测试的网站都经 {} 的{},没有暴露本机网络的地址。 All tested sites go through {}'s {}, so your network's own address isn't exposed. 测速 Test 浏览器 Browsers +淘宝 Taobao 深色 Dark 深色菜单栏预览 Dark menu bar preview 清理 Cleanup @@ -825,8 +887,10 @@ npm 缓存 npm Cache 登录时启动 Launch at Login 登录时运行 Runs at login 登录项设置 Login Items +百度 Baidu 监控 Monitor 目标转速 Target speed +直连 Direct 相当于“磁盘工具”里的急救,但只检查不修改:核对启动盘的目录结构、文件分配与快照元数据是否一致。 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 @@ -837,6 +901,7 @@ npm 缓存 npm Cache 磁盘清理 Disk Cleanup 磁盘空间不足 Low Disk Space 磁盘空间偏紧 Disk space is tight +社交 Social 移到废纸篓 Move to Trash 移到废纸篓? Move to the Trash? 移动网络 Mobile @@ -858,6 +923,7 @@ npm 缓存 npm Cache 签名校验未通过:{} Signature check failed: {} 粘贴 Paste 系统 System +系统代理 System Proxy 系统或其他用户的进程,OpenStats 不提供结束 A system or other user's process; OpenStats can't end it 系统服务 System Services 系统正在把内存写到磁盘,可能会变慢。可以关掉占用大的应用。 The system is swapping memory to disk and may slow down. Quit apps that use a lot of memory. @@ -877,6 +943,9 @@ npm 缓存 npm Cache 线程 {} Threads {} 线程数(只能读取自己的进程) Thread count (your processes only) 经 Cloudflare {} 边缘节点 Via the Cloudflare {} edge +经 {} Via {} +经 {} 直接连接 Direct via {} +经代理 Proxied 结束 {} 失败:{} Failed to end {}: {} 结束“{}”? End “{}”? 结束它会注销当前用户,已禁止 Ending it would log you out, so it's blocked @@ -889,11 +958,16 @@ npm 缓存 npm Cache 缓存与日志直接删除,下载内容移到废纸篓。 Caches and logs are deleted; downloads go to the Trash. 缓存也先移到废纸篓 Move caches to Trash too 编辑 Edit +网站 Site +网站分流 Site Routing +网站没有接入 Cloudflare,读不到出口,按国内 / 国际规则归到对应出口 This site isn't behind Cloudflare, so its exit can't be read. It's grouped by the China / international rule. 网络 Network +网络不通 No Connection 网络名称 Network name 网络已恢复 Network Restored 网络断开 Network Down 网络测速 Network Speed Test +网络环境变了,下面是之前的结果,正在重新检测。 The network changed. These are the previous results; checking again. 网络类型 Network type 网络详情打开时每秒 ping 一次,通了是绿格、不通是红格,显示最近 60 次 Pings once a second while network details are open: green when reachable, red when not, showing the last 60 网络运营方 Network operator @@ -910,6 +984,7 @@ npm 缓存 npm Cache 腾讯(119.29.29.29) Tencent (119.29.29.29) 自 {} 起 Since {} 自动 Auto +自动代理配置 Auto proxy config 自动检查更新 Check Automatically 自动(由路由器分配) Automatic (from router) 自定义 Custom @@ -918,6 +993,7 @@ npm 缓存 npm Cache 自己的进程为实际占用内存,系统进程为常驻内存 Footprint for your processes, resident memory for system processes 致谢 Acknowledgements 良好 Good +节点 Edge 芯片 Chip 菜单栏 Menu Bar 菜单栏图标 Menu Bar Icons @@ -949,6 +1025,7 @@ npm 缓存 npm Cache 读 {} · 写 {} Read {} · Write {} 读写最多的应用 Top Disk Activity 读写速度 Read & Write +读到了这次连接的出口 The exit of this connection was read directly 读取 Read 读取中 Loading 调节风扇需要安装辅助工具,仅需管理员授权一次。 Fan control needs the helper, which requires one-time admin approval. @@ -961,6 +1038,7 @@ npm 缓存 npm Cache 账号与同步 Account & Sync 走势图显示最近多长时间 How far back the trend chart shows 走势时长 Trend duration +超时 Timed out 超级核 Super 超过 1 天未更新的 .crdownload / .part / .download .crdownload / .part / .download files untouched for over a day 跟随整体 Follow global @@ -987,6 +1065,7 @@ npm 缓存 npm Cache 还有 {} 个 {} more 还没有足够的记录。菜单栏显示电池或打开这个页面时,每分钟记录一次电量 Not enough records yet. The level is recorded every minute while Battery is in the menu bar or this page is open 还能用 {} {} left +这一类没有检测结果 No results in this category 这个小时的免费额度用完了,{} 分钟后可以再试 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. @@ -1003,6 +1082,7 @@ npm 缓存 npm Cache 进程名:{} Process name: {} 进程启动以来累计占用的 CPU 时间 Total CPU time since the process started 进程页刷新频率调整为每 2 秒 Processes page now refreshes every 2 seconds +连不上 Unreachable 连接探测 Connectivity 连接探测历史 Probe History 连接探测只在需要时运行,更省电 Connectivity probing runs only when needed, saving power @@ -1019,6 +1099,7 @@ npm 缓存 npm Cache 通知可以正常显示。发生你打开的状况时,会像这样提醒你。 Notifications are working. You'll be alerted like this when something you turned on happens. 通过 {} 登录 Signed in with {} 速度与省电介于两者之间 In between on speed and efficiency +部分网站直连、部分经代理,每个网站走哪个出口见下方。 Some sites connect directly and some go through a proxy. See below for the exit each site uses. 部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。 Some items need Full Disk Access to scan (Safari caches, Trash). 配置方式 Configuration 释放内存 Free Memory @@ -1027,6 +1108,7 @@ npm 缓存 npm Cache 重新安装 Reinstall 重新扫描 Rescan 重新查询公网 IP Look Up Public IP Again +重新检测 Check Again 重新生成 Regenerate 重新读取蓝牙设备电量 Read Bluetooth device batteries again 重置上传与下载统计:从现在起重新累计。重启后自动回到开机后的累计;右键可改回 Reset the upload and download totals and count from now on. After a restart they go back to since-boot totals; right-click to switch back diff --git a/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift b/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift index cd2c425..31412ef 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/AddressFamilyRequest.swift @@ -8,22 +8,41 @@ import Network /// 请求写成最简单的 HTTP/1.1(Connection: close,不要压缩),读到对方关闭为止。 /// 这一族没有路由时连接会进入 waiting,直接当失败处理,不干等超时。 enum AddressFamilyRequest { + /// 出口检测用的额外选项:默认与查纯净度时一样,走系统路由、只锁定地址族 + struct Options: @unchecked Sendable { + /// 绑定到这块网卡发出,绕开 VPN 隧道 + var interface: NWInterface? + /// 直接连这个 IP,TLS 与 Host 仍用 URL 里的域名。绑定物理网卡时系统 DNS 可能被代理改成 fake-ip,只能自己解析 + var address: String? + /// 忽略系统里配置的代理 + var bypassProxies = false + var accept = "application/json" + } + /// 返回响应体与状态码;连接失败时状态码为 0 - static func get(_ url: URL, ipv6: Bool, timeout: TimeInterval = 10) async -> (Data?, Int) { - guard url.scheme == "https", let host = url.host, !host.isEmpty else { return (nil, 0) } + static func get(_ url: URL, ipv6: Bool, timeout: TimeInterval = 10, options: Options = Options()) async -> (Data?, Int) { + // IPv6 字面量的 host 可能带着方括号 + guard url.scheme == "https", + let host = url.host?.trimmingCharacters(in: CharacterSet(charactersIn: "[]")), !host.isEmpty else { return (nil, 0) } var path = url.path.isEmpty ? "/" : url.path if let query = url.query { path += "?" + query } let tls = NWProtocolTLS.Options() sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1") + if options.address != nil { + sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host) + } let tcp = NWProtocolTCP.Options() tcp.connectionTimeout = Int(timeout) let parameters = NWParameters(tls: tls, tcp: tcp) if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { ip.version = ipv6 ? .v6 : .v4 } - let connection = NWConnection(host: NWEndpoint.Host(host), port: .https, using: parameters) - let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nUser-Agent: OpenStats\r\nAccept: application/json\r\nConnection: close\r\n\r\n" + if let interface = options.interface { parameters.requiredInterface = interface } + parameters.preferNoProxies = options.bypassProxies + let connection = NWConnection(host: NWEndpoint.Host(options.address ?? host), port: .https, using: parameters) + let hostHeader = host.contains(":") ? "[\(host)]" : host + let request = "GET \(path) HTTP/1.1\r\nHost: \(hostHeader)\r\nUser-Agent: OpenStats\r\nAccept: \(options.accept)\r\nConnection: close\r\n\r\n" guard let raw = await Exchange(connection: connection, request: Data(request.utf8), timeout: timeout).run() else { return (nil, 0) } diff --git a/Packages/OpenStatsKit/Sources/Metrics/EgressAnalysis.swift b/Packages/OpenStatsKit/Sources/Metrics/EgressAnalysis.swift new file mode 100644 index 0000000..4c4ab1f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/EgressAnalysis.swift @@ -0,0 +1,176 @@ +import Darwin +import Foundation + +/// 把三条路线的测量结果归纳成结论:分流是否生效、各网站从哪个出口出去、IPv6 与系统代理有没有漏洞。 +/// +/// 关键在“原始出口”:绑定物理网卡、绕开 VPN 与代理测到的地址。某个目标按系统代理访问时的出口 +/// 与原始出口相同就是直连,不同就是经过了代理。网页版读不到原始出口,只能拿国内回显去猜 +public struct EgressAnalysis: Sendable, Equatable { + public enum Verdict: Sendable, Equatable { + /// 所有目标都连不上 + case offline + /// 没有配置代理,全部直连 + case noProxy + /// 配置了代理,但测试的目标都在直连 + case proxyInactive + /// 国内直连,国际网站全部经代理 + case split + /// 按规则分流:有的直连、有的经代理,但不是“国内直连、国际代理”这种标准分法 + case mixed + /// 全部经代理 + case fullProxy + /// VPN 不让绕开它连接,读不到原始出口,分不清哪个出口是代理 + case unverified + } + + public enum Path: Sendable, Equatable { + case direct, proxy, unknown + } + + /// 一个出口与经它出去的目标 + public struct Exit: Sendable, Equatable, Identifiable { + public struct Entry: Sendable, Equatable { + public var targetID: String + /// 实测:读到了这次连接的出口;否则是按国内 / 国际规则推断的 + public var measured: Bool + } + + public var ip: String + public var path: Path + public var entries: [Entry] + /// Cloudflare 给出的国家,归属地查不到时用它 + public var country: String? + public var id: String { ip } + } + + public enum Finding: Sendable, Equatable { + /// 隧道接管了 IPv4,IPv6 却没经过它,暴露了这个地址 + case ipv6Leak(String) + /// 代理开着时 IPv6 连不出去:被拦截了,不会泄露 + case ipv6Blocked + /// IPv6 也经过代理 + case ipv6Proxied + /// 只设了系统代理,不读代理设置的程序仍在直连 + case socketBypass + /// 本机在中国大陆,国内网站却走了代理 + case domesticProxied + } + + public var verdict: Verdict + /// 原始出口:绑定物理网卡测到的地址 + public var rawAddresses: [String] + public var rawCountry: String? + /// 按系统代理访问各目标时用到的出口,经代理的在前 + public var exits: [Exit] + /// 按系统代理连不上的目标(IPv6 基准目标不算,没有 IPv6 很常见) + public var failedTargetIDs: [String] + /// 连得上,但读不到出口,也没有可参照的出口 + public var unassignedTargetIDs: [String] + public var findings: [Finding] + /// 结论依据:实测出口里直连与经代理的数量 + public var directCount: Int + public var proxyCount: Int + + public func path(of ip: String) -> Path { + guard !rawAddresses.isEmpty else { return .unknown } + return rawAddresses.contains { Self.sameExit($0, ip) } ? .direct : .proxy + } + + public static func hit(_ hits: [EgressHit], _ target: EgressTarget, _ route: EgressRoute) -> EgressHit? { + hits.first { $0.targetID == target.id && $0.route == route } + } + + public static func evaluate(_ hits: [EgressHit], environment: ProxyEnvironment) -> EgressAnalysis { + func ip(_ target: EgressTarget, _ route: EgressRoute) -> String? { hit(hits, target, route)?.ip } + + let raw = [ip(.domestic, .interface), ip(.cloudflare, .interface), ip(.cloudflareIPv6, .interface)].compactMap { $0 } + let rawCountry = hits.lazy.filter { $0.route == .interface }.compactMap(\.country).first + var analysis = EgressAnalysis(verdict: .offline, rawAddresses: raw, rawCountry: rawCountry, exits: [], + failedTargetIDs: [], unassignedTargetIDs: [], findings: [], + directCount: 0, proxyCount: 0) + let system = hits.filter { $0.route == .system } + let ipv6 = EgressTarget.cloudflareIPv6.id + + var exits: [Exit] = [] + func add(_ address: String, _ entry: Exit.Entry, country: String?) { + if let index = exits.firstIndex(where: { $0.ip == address }) { + exits[index].entries.append(entry) + exits[index].country = exits[index].country ?? country + } else { + exits.append(Exit(ip: address, path: analysis.path(of: address), entries: [entry], country: country)) + } + } + for hit in system { + guard let address = hit.ip else { continue } + add(address, .init(targetID: hit.targetID, measured: true), country: hit.country) + } + // 读不到出口的网站:国内的归到国内回显的出口,国外的归到 Cloudflare 的出口(没有就取国外网站最常见的出口) + let international = ip(.cloudflare, .system) ?? system + .filter { $0.targetID != EgressTarget.domestic.id && $0.targetID != ipv6 } + .compactMap(\.ip) + .reduce(into: [String: Int]()) { $0[$1, default: 0] += 1 } + .max { $0.value < $1.value }?.key + for hit in system where hit.ip == nil && hit.milliseconds != nil { + let region = EgressTarget.all.first { $0.id == hit.targetID }?.region + if let reference = region == .domestic ? ip(.domestic, .system) : international { + add(reference, .init(targetID: hit.targetID, measured: false), country: nil) + } else { + analysis.unassignedTargetIDs.append(hit.targetID) + } + } + // 经代理的在前,其次读不出的,直连最后;同类按目标数从多到少,再按出现顺序 + let rank: (Path) -> Int = { $0 == .proxy ? 0 : $0 == .unknown ? 1 : 2 } + analysis.exits = exits.enumerated().sorted { lhs, rhs in + let left = (rank(lhs.element.path), -lhs.element.entries.count, lhs.offset) + let right = (rank(rhs.element.path), -rhs.element.entries.count, rhs.offset) + return left < right + }.map(\.element) + analysis.failedTargetIDs = system.filter { $0.milliseconds == nil && $0.targetID != ipv6 }.map(\.targetID) + + // 结论只数实测出口(IPv4 基准与接入 Cloudflare 的网站);IPv6 基准单独在下面判断 + let counted = system.compactMap { hit in hit.targetID == ipv6 ? nil : hit.ip.map { (hit.targetID, analysis.path(of: $0)) } } + analysis.directCount = counted.filter { $0.1 == .direct }.count + analysis.proxyCount = counted.filter { $0.1 == .proxy }.count + let domestic = counted.first { $0.0 == EgressTarget.domestic.id }?.1 + let internationalDirect = counted.contains { $0.0 != EgressTarget.domestic.id && $0.1 == .direct } + + if counted.isEmpty { + analysis.verdict = .offline + } else if raw.isEmpty { + analysis.verdict = .unverified + } else if analysis.proxyCount == 0 { + analysis.verdict = environment.hasProxy ? .proxyInactive : .noProxy + } else if analysis.directCount == 0 { + analysis.verdict = .fullProxy + } else if domestic == .direct && !internationalDirect { + analysis.verdict = .split + } else { + analysis.verdict = .mixed + } + + guard analysis.proxyCount > 0 else { return analysis } + // 不读代理设置的连接:IPv4 也直连说明只有系统代理,IPv6 泄露就无从谈起;IPv4 被接管了才看 IPv6 + if let socketV4 = ip(.cloudflare, .socket), analysis.path(of: socketV4) == .direct { + analysis.findings.append(.socketBypass) + } else if let rawV6 = ip(.cloudflareIPv6, .interface) { + if let socketV6 = ip(.cloudflareIPv6, .socket) { + analysis.findings.append(sameExit(socketV6, rawV6) ? .ipv6Leak(socketV6) : .ipv6Proxied) + } else { + analysis.findings.append(.ipv6Blocked) + } + } + if rawCountry == "CN", domestic == .proxy, analysis.verdict == .mixed { + analysis.findings.append(.domesticProxied) + } + return analysis + } + + /// IPv4 比整个地址;IPv6 比前 64 位:同一个网络里,不同连接可能用不同的临时地址 + static func sameExit(_ lhs: String, _ rhs: String) -> Bool { + if lhs == rhs { return true } + var left = in6_addr() + var right = in6_addr() + guard inet_pton(AF_INET6, lhs, &left) == 1, inet_pton(AF_INET6, rhs, &right) == 1 else { return false } + return withUnsafeBytes(of: &left) { a in withUnsafeBytes(of: &right) { b in a.prefix(8).elementsEqual(b.prefix(8)) } } + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift b/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift new file mode 100644 index 0000000..37574cf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/EgressProbe.swift @@ -0,0 +1,351 @@ +import Foundation +import Localization +import Network + +/// 出口检测的一个目标:CleanIP 的国内回显、Cloudflare,或一个常用网站。 +/// 做法参照 cleanip.io/outbound:回显端点直接返回它看到的来源 IP;接入 Cloudflare 的网站读 /cdn-cgi/trace, +/// 能实测访问它时的出口、节点与国家;其余网站只能测连通与延迟,出口按规则推断 +public struct EgressTarget: Sendable, Hashable, Identifiable { + public enum Region: String, Sendable, Codable { case domestic, international } + + public enum Category: String, Sendable, CaseIterable { + case ai, media, social, developer, common, domestic + } + + enum Probe: Sendable, Hashable { + /// Cloudflare 的 /cdn-cgi/trace + case trace + /// 回显 JSON:{"ip": "…"} + case echo(path: String) + /// 只测连通与延迟:请求一个很小的资源,收到任何 HTTP 响应都算通 + case reach(path: String, head: Bool) + } + + public let id: String + public let name: String + public let host: String + public let category: Category + /// Resources/Logos 里的图标名 + public let logo: String? + let probe: Probe + + public var region: Region { category == .domestic || id == Self.domestic.id ? .domestic : .international } + + /// 能读出这次连接的出口;否则只知道通不通 + public var measuresExit: Bool { + if case .reach = probe { return false } + return true + } + + var isIPv6Literal: Bool { host.contains(":") } + + var url: URL { + let path = switch probe { + case .trace: "/cdn-cgi/trace" + case .echo(let path), .reach(let path, _): path + } + return URL(string: "https://\(isIPv6Literal ? "[\(host)]" : host)\(path)")! + } + + /// CleanIP 的国内回显机,用 IP 访问:代理规则里的 GEOIP,CN 能命中它,按域名分流的用户也会直连 + public static let domestic = EgressTarget(id: "domestic", name: "CleanIP", host: "124.222.230.137", + category: .common, logo: nil, probe: .echo(path: "/")) + /// 用 IP 访问,不经过 DNS,也就不受 fake-ip 与域名规则影响 + public static let cloudflare = EgressTarget(id: "cloudflare", name: "Cloudflare", host: "1.1.1.1", + category: .common, logo: nil, probe: .trace) + public static let cloudflareIPv6 = EgressTarget(id: "cloudflare-ipv6", name: "Cloudflare", host: "2606:4700:4700::1111", + category: .common, logo: nil, probe: .trace) + + /// 三条路线都要测的基准目标 + public static let baseline = [domestic, cloudflare, cloudflareIPv6] + + /// 精选网站,只按系统代理测。能读 trace 的优先用 trace(实测出口) + public static let sites: [EgressTarget] = [ + trace("chatgpt.com", "ChatGPT", .ai, "openai"), + trace("claude.ai", "Claude", .ai, "claude"), + reach("gemini.google.com", "Gemini", .ai, "gemini"), + trace("grok.com", "Grok", .ai, "grok"), + trace("www.perplexity.ai", "Perplexity", .ai, "perplexity"), + trace("www.midjourney.com", "Midjourney", .ai, "midjourney"), + reach("www.youtube.com", "YouTube", .media, "youtube", path: "/generate_204", head: false), + reach("www.netflix.com", "Netflix", .media, "netflix"), + reach("www.tiktok.com", "TikTok", .media, "tiktok"), + reach("www.twitch.tv", "Twitch", .media, "twitch"), + trace("x.com", "X", .social, "x"), + reach("telegram.org", "Telegram", .social, "telegram"), + reach("www.instagram.com", "Instagram", .social, "instagram"), + reach("discord.com", "Discord", .social, "discord"), + reach("www.reddit.com", "Reddit", .social, "reddit"), + reach("github.com", "GitHub", .developer, "github"), + trace("www.npmjs.com", "npm", .developer, "npm"), + trace("hub.docker.com", "Docker Hub", .developer, "docker"), + reach("www.google.com", "Google", .common, "google", path: "/generate_204", head: false), + trace("www.cloudflare.com", "Cloudflare", .common, "cloudflare"), + reach("www.baidu.com", tr("百度"), .domestic, "baidu"), + reach("www.taobao.com", tr("淘宝"), .domestic, "taobao"), + reach("www.bilibili.com", tr("哔哩哔哩"), .domestic, "bilibili"), + reach("www.douyin.com", tr("抖音"), .domestic, "douyin"), + reach("weixin.qq.com", tr("微信"), .domestic, "wechat"), + ] + + public static let all = baseline + sites + + private static func trace(_ host: String, _ name: String, _ category: Category, _ logo: String) -> EgressTarget { + EgressTarget(id: host, name: name, host: host, category: category, logo: "site-" + logo, probe: .trace) + } + + /// 默认用 HEAD 请求 favicon:响应小、不跳转到大页面 + private static func reach(_ host: String, _ name: String, _ category: Category, _ logo: String, + path: String = "/favicon.ico", head: Bool = true) -> EgressTarget { + EgressTarget(id: host, name: name, host: host, category: category, logo: "site-" + logo, probe: .reach(path: path, head: head)) + } +} + +/// 请求怎么发出去 +public enum EgressRoute: String, Sendable, Codable, CaseIterable { + /// 按系统代理设置发出(URLSession),浏览器等大多数应用都是这样 + case system + /// 不读代理设置,直接建连接,走系统路由表;VPN 或增强模式会接管它 + case socket + /// 绑定物理网卡并忽略代理,绕开 VPN 与代理,得到本机网络原本的出口 + case interface +} + +/// 一次测量:某个目标经某条路线看到的出口 +public struct EgressHit: Sendable, Codable, Equatable { + public var targetID: String + public var route: EgressRoute + public var ip: String? + /// Cloudflare 给出的出口国家 + public var country: String? + /// Cloudflare 接入节点(机场代码) + public var colo: String? + public var milliseconds: Int? + + public init(targetID: String, route: EgressRoute, ip: String? = nil, country: String? = nil, colo: String? = nil, + milliseconds: Int? = nil) { + self.targetID = targetID + self.route = route + self.ip = ip + self.country = country + self.colo = colo + self.milliseconds = milliseconds + } +} + +/// 出口 IP 的归属地与类型,来自 cleanip.io +public struct EgressGeo: Sendable, Codable, Equatable { + public var countryCode: String? + public var region: String? + public var city: String? + public var regionEnglish: String? + public var cityEnglish: String? + public var asn: String? + public var organization: String? + public var ipType: String? + public var isNative: Bool? + + init(_ info: PublicAddressLookup.GeoInfo) { + countryCode = info.countryCode + region = info.region + city = info.city + regionEnglish = info.regionEnglish + cityEnglish = info.cityEnglish + asn = info.asn + organization = info.organization + ipType = info.ipType + isNative = info.isNative + } +} + +public enum EgressProber { + static let timeout: TimeInterval = 5 + /// 同时进行的请求数:太多会把慢线路上的正常站点挤成超时 + static let concurrency = 10 + + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + 2 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpShouldSetCookies = false + configuration.waitsForConnectivity = false + configuration.httpAdditionalHeaders = ["User-Agent": "OpenStats"] + return URLSession(configuration: configuration) + }() + + /// 所有要做的测量:基准目标三条路线各一次在前(结论靠它们),精选网站只按系统代理测 + public static var jobCount: Int { EgressTarget.baseline.count * 3 + EgressTarget.sites.count } + + /// `progress` 每完成一项回调一次(已完成数) + public static func measure(interfaceName: String?, progress: @escaping @Sendable (Int) -> Void = { _ in }) async -> [EgressHit] { + let interface = await physicalInterface(named: interfaceName) + var jobs: [(EgressTarget, EgressRoute)] = [] + for target in EgressTarget.baseline { + jobs.append((target, .system)) + jobs.append((target, .socket)) + jobs.append((target, .interface)) + } + jobs += EgressTarget.sites.map { ($0, .system) } + + return await withTaskGroup(of: (Int, EgressHit).self) { group in + var results = [EgressHit?](repeating: nil, count: jobs.count) + var next = 0 + var done = 0 + while next < min(concurrency, jobs.count) { + let (index, job) = (next, jobs[next]) + group.addTask { (index, await measure(job.0, route: job.1, interface: interface)) } + next += 1 + } + for await (index, hit) in group { + results[index] = hit + done += 1 + progress(done) + if next < jobs.count { + let (index, job) = (next, jobs[next]) + group.addTask { (index, await measure(job.0, route: job.1, interface: interface)) } + next += 1 + } + } + return results.compactMap { $0 } + } + } + + /// 系统代理路线先预热一次(建连、握手、代理选路),再计时请求一次,延迟更接近真实往返; + /// 另外两条路线只用于读出口,请求一次即可 + static func measure(_ target: EgressTarget, route: EgressRoute, interface: NWInterface?) async -> EgressHit { + var hit = EgressHit(targetID: target.id, route: route) + if route == .interface && interface == nil { return hit } + let clock = ContinuousClock() + var start = clock.now + guard var response = await fetch(target, route: route, interface: interface) else { return hit } + if route == .system { + start = clock.now + if let timed = await fetch(target, route: route, interface: interface) { response = timed } + } + let elapsed = clock.now - start + hit.milliseconds = Int((Double(elapsed.components.seconds) * 1000 + Double(elapsed.components.attoseconds) / 1e15).rounded()) + + switch target.probe { + case .trace: + let fields = PublicAddressLookup.parseTrace(String(decoding: response, as: UTF8.self)) + hit.ip = fields["ip"] + hit.country = fields["loc"].flatMap(PublicAddressLookup.validCountryCode) + hit.colo = fields["colo"].flatMap { $0.isEmpty ? nil : $0 } + case .echo: + let ip = (try? JSONDecoder().decode([String: String].self, from: response))?["ip"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + hit.ip = ip.flatMap { PublicAddressLookup.isAddress($0) ? $0 : nil } + case .reach: + break + } + // 基准目标就是用来读出口的,没读到地址(比如被拦截页顶替)就算没连上;网站只要有响应就算通 + if EgressTarget.baseline.contains(target) && hit.ip == nil { hit.milliseconds = nil } + return hit + } + + /// 连上了返回响应体(HEAD 为空数据);读出口的目标要求 200,只测连通的收到任何 HTTP 响应都算 + private static func fetch(_ target: EgressTarget, route: EgressRoute, interface: NWInterface?) async -> Data? { + switch route { + case .system: + var request = URLRequest(url: target.url) + if case .reach(_, let head) = target.probe, head { request.httpMethod = "HEAD" } + guard let (data, response) = try? await session.data(for: request), + let status = (response as? HTTPURLResponse)?.statusCode else { return nil } + return target.measuresExit ? (status == 200 ? data : nil) : data + case .socket: + return ok(await AddressFamilyRequest.get(target.url, ipv6: target.isIPv6Literal, timeout: timeout, + options: .init(bypassProxies: true))) + case .interface: + guard let interface else { return nil } + return ok(await AddressFamilyRequest.get(target.url, ipv6: target.isIPv6Literal, timeout: timeout, + options: .init(interface: interface, bypassProxies: true))) + } + } + + // MARK: 归属地 + + /// 查某条路线出口的归属地。cleanip.io 只查请求方自己,所以请求必须沿这条路线发出: + /// 系统代理路线直接请求;网卡路线先经同一块网卡向 Cloudflare DoH 解析 cleanip.io,再连解析到的 IP, + /// 避免拿到代理下发的 fake-ip。返回 cleanip.io 看到的地址与结果 + public static func geo(route: EgressRoute, ipv6: Bool, interfaceName: String?) async -> (ip: String, geo: EgressGeo)? { + let url = PublicAddressLookup.cleanIPURL + let body: Data? + switch route { + case .system: + body = await get(url) + case .socket: + body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, options: .init(bypassProxies: true))) + case .interface: + guard let interface = await physicalInterface(named: interfaceName), + let address = await resolve("cleanip.io", ipv6: ipv6, interface: interface) else { return nil } + body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, + options: .init(interface: interface, address: address, bypassProxies: true))) + } + guard let body, let info = PublicAddressLookup.parseCleanIP(body), let ip = info.ip else { return nil } + return (ip, EgressGeo(info)) + } + + /// 经指定网卡用 Cloudflare DoH(JSON 格式)解析域名 + static func resolve(_ name: String, ipv6: Bool, interface: NWInterface) async -> String? { + let server = ipv6 ? "[2606:4700:4700::1111]" : "1.1.1.1" + guard let url = URL(string: "https://\(server)/dns-query?name=\(name)&type=\(ipv6 ? "AAAA" : "A")"), + let body = ok(await AddressFamilyRequest.get(url, ipv6: ipv6, timeout: timeout, + options: .init(interface: interface, bypassProxies: true, + accept: "application/dns-json"))) else { return nil } + return parseDoH(body, ipv6: ipv6) + } + + static func parseDoH(_ body: Data, ipv6: Bool) -> String? { + struct Response: Decodable { + struct Answer: Decodable { + let type: Int + let data: String + } + let Answer: [Answer]? + } + let type = ipv6 ? 28 : 1 + return (try? JSONDecoder().decode(Response.self, from: body))?.Answer? + .first { $0.type == type && PublicAddressLookup.isAddress($0.data) }?.data + } + + // MARK: 工具 + + private static func get(_ url: URL) async -> Data? { + ok(await PublicAddressLookup.getWithStatus(url, session: session)) + } + + /// 按接口名找到 Network.framework 的网卡对象;VPN 开着时它仍在可用接口里 + static func physicalInterface(named name: String?) async -> NWInterface? { + let interfaces = await withCheckedContinuation { (continuation: CheckedContinuation<[NWInterface], Never>) in + let monitor = NWPathMonitor() + let resumed = ResumeOnce() + monitor.pathUpdateHandler = { path in + guard resumed.claim() else { return } + monitor.cancel() + continuation.resume(returning: path.availableInterfaces) + } + monitor.start(queue: DispatchQueue(label: "OpenStats.EgressProber.interfaces")) + } + if let name, let match = interfaces.first(where: { $0.name == name }) { return match } + return interfaces.first { $0.type == .wifi || $0.type == .wiredEthernet } + } +} + +private final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var done = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !done else { return false } + done = true + return true + } +} + +/// 只要 200 的响应体 +private func ok(_ response: (Data?, Int)) -> Data? { + response.1 == 200 ? response.0 : nil +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/ProxyEnvironment.swift b/Packages/OpenStatsKit/Sources/Metrics/ProxyEnvironment.swift new file mode 100644 index 0000000..85dca92 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Metrics/ProxyEnvironment.swift @@ -0,0 +1,103 @@ +import CFNetwork +import Darwin +import Foundation + +/// 本机的代理环境:VPN / 增强模式隧道、系统代理、DNS 是否被代理接管、正在运行的代理软件。 +/// 只读系统配置,不发任何请求 +public struct ProxyEnvironment: Sendable, Codable, Equatable { + public struct Proxy: Sendable, Codable, Equatable { + public enum Kind: String, Sendable, Codable { case http, https, socks } + public var kind: Kind + public var host: String + public var port: Int + } + + /// 默认路由所在隧道的服务名(例如 Surge)与接口(utun6) + public var tunnelName: String? + public var tunnelInterface: String? + public var proxies: [Proxy] + /// 自动代理配置(PAC)地址 + public var pacURL: String? + public var dnsServers: [String] + /// 承载流量的物理网卡,绕开代理时绑定它 + public var physicalInterface: String? + public var physicalName: String? + public var localIPv4: [String] + /// 正在运行的代理 / VPN 软件 + public var apps: [String] + + public init(tunnelName: String? = nil, tunnelInterface: String? = nil, proxies: [Proxy] = [], pacURL: String? = nil, + dnsServers: [String] = [], physicalInterface: String? = nil, physicalName: String? = nil, + localIPv4: [String] = [], apps: [String] = []) { + self.tunnelName = tunnelName + self.tunnelInterface = tunnelInterface + self.proxies = proxies + self.pacURL = pacURL + self.dnsServers = dnsServers + self.physicalInterface = physicalInterface + self.physicalName = physicalName + self.localIPv4 = localIPv4 + self.apps = apps + } + + /// 配置了任何一种代理:隧道、系统代理或 PAC + public var hasProxy: Bool { tunnelInterface != nil || !proxies.isEmpty || pacURL != nil } + + /// DNS 指向 198.18.0.0/15:Surge、Clash 等的 fake-ip,域名解析由代理接管 + public var usesFakeIPDNS: Bool { dnsServers.contains(where: Self.isFakeIP) } + + /// 网络环境的指纹:隧道、代理、网卡或本地地址变了,之前的检测结果就不再代表当前网络 + public var signature: String { + [tunnelInterface ?? "", proxies.map { "\($0.kind.rawValue)\($0.host):\($0.port)" }.joined(separator: ","), + pacURL ?? "", physicalInterface ?? "", localIPv4.joined(separator: ","), dnsServers.joined(separator: ",")] + .joined(separator: "|") + } + + static func isFakeIP(_ address: String) -> Bool { + var v4 = in_addr() + guard inet_pton(AF_INET, address, &v4) == 1 else { return false } + let value = UInt32(bigEndian: v4.s_addr) + return value >> 17 == (198 << 24 | 18 << 16) >> 17 + } +} + +public enum ProxyEnvironmentReader { + /// 按应用名识别的常见代理与 VPN 软件 + static let knownApps = [ + "Surge", "ClashX", "ClashX Pro", "ClashX Meta", "Clash Verge", "Clash Nyanpasu", "Mihomo Party", "FlClash", + "Stash", "Shadowrocket", "Quantumult X", "Loon", "sing-box", "SFM", "Hiddify", "V2rayU", "V2RayXS", "NekoRay", + "Tailscale", "WireGuard", "Cloudflare WARP", "Mullvad VPN", "NordVPN", "ExpressVPN", "Proton VPN", "Surfshark", + "Astrill", "Outline", "Tunnelblick", "Viscosity", "OpenVPN Connect", "GlobalProtect", "Cisco Secure Client", + "FortiClient", "Zscaler", + ] + + /// `runningAppNames` 由界面层从 NSWorkspace 取,这里不依赖 AppKit + public static func read(details: NetworkDetails, runningAppNames: [String]) -> ProxyEnvironment { + let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] ?? [:] + let (proxies, pac) = parseProxies(settings) + return ProxyEnvironment(tunnelName: details.tunnel?.name, tunnelInterface: details.tunnel?.bsdName, + proxies: proxies, pacURL: pac, dnsServers: details.dnsServers, + physicalInterface: details.physical?.bsdName, physicalName: details.physical?.displayName, + localIPv4: details.physical?.ipv4 ?? [], apps: proxyApps(in: runningAppNames)) + } + + static func parseProxies(_ settings: [String: Any]) -> ([ProxyEnvironment.Proxy], pac: String?) { + func enabled(_ key: String) -> Bool { (settings[key] as? NSNumber)?.boolValue == true } + var proxies: [ProxyEnvironment.Proxy] = [] + for (kind, prefix) in [(ProxyEnvironment.Proxy.Kind.http, "HTTP"), (.https, "HTTPS"), (.socks, "SOCKS")] { + guard enabled("\(prefix)Enable"), let host = settings["\(prefix)Proxy"] as? String, !host.isEmpty else { continue } + let port = (settings["\(prefix)Port"] as? NSNumber)?.intValue ?? 0 + proxies.append(.init(kind: kind, host: host, port: port)) + } + let pac = enabled("ProxyAutoConfigEnable") ? (settings["ProxyAutoConfigURLString"] as? String).flatMap { $0.isEmpty ? nil : $0 } : nil + return (proxies, pac) + } + + static func proxyApps(in names: [String]) -> [String] { + var found: [String] = [] + for name in names where knownApps.contains(where: { name == $0 || name.hasPrefix($0 + " ") }) && !found.contains(name) { + found.append(name) + } + return found + } +} diff --git a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift index f6d4a81..0dcf80e 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/PublicAddressLookup.swift @@ -322,6 +322,10 @@ public enum PublicAddressLookup { } static func getWithStatus(_ url: URL) async -> (Data?, Int) { + await getWithStatus(url, session: session) + } + + static func getWithStatus(_ url: URL, session: URLSession) async -> (Data?, Int) { guard let (data, response) = try? await session.data(from: url) else { return (nil, 0) } return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 08a4138..24819a0 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -10,6 +10,7 @@ public final class AppController: NSObject, NSApplicationDelegate { private var mainWindow: MainWindowController! private var updateWindow: UpdateWindowController! private var speedTestWindow: SpeedTestWindowController! + private var egressWindow: EgressWindowController! private var updateTimer: Timer? private let hotKeys = HotKeyCenter() private var workspaceObservers: [NSObjectProtocol] = [] @@ -30,6 +31,8 @@ public final class AppController: NSObject, NSApplicationDelegate { updateWindow = UpdateWindowController(model: model) speedTestWindow = SpeedTestWindowController(model: model) speedTestWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } + egressWindow = EgressWindowController(model: model) + egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() } menuBar.update() // 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置 @@ -41,6 +44,10 @@ public final class AppController: NSObject, NSApplicationDelegate { self?.menuBar.dismissPopovers() self?.mainWindow.show(tab: tab) } + model.openEgressWindow = { [weak self] in + self?.menuBar.dismissPopovers() + self?.egressWindow.show() + } model.openSpeedTestWindow = { [weak self] in self?.menuBar.dismissPopovers() self?.speedTestWindow.show() @@ -72,7 +79,8 @@ public final class AppController: NSObject, NSApplicationDelegate { model.sync.presentationAnchor = { [weak self] in self?.mainWindow.nsWindow } model.sync.start() - // 开发调试:--show-panel [cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗;--show-window 打开主窗口 + // 开发调试:--show-panel [cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗;--show-window 打开主窗口; + // --show-egress 打开出口与分流窗口 let arguments = CommandLine.arguments if let index = arguments.firstIndex(of: "--show-panel") { let item = arguments.dropFirst(index + 1).first.flatMap(MenuBarItem.init(rawValue:)) ?? .network @@ -93,6 +101,9 @@ public final class AppController: NSObject, NSApplicationDelegate { model.speedTest.globalpingTarget = domain model.speedTest.runGlobalping() } + if arguments.contains("--show-egress") { + egressWindow.show() + } // 开发调试:--explain-process <进程名> 打开进程页并用 Apple 智能解释该进程 if let index = arguments.firstIndex(of: "--explain-process"), let name = arguments.dropFirst(index + 1).first { mainWindow.show(tab: .processes) @@ -177,7 +188,7 @@ public final class AppController: NSObject, NSApplicationDelegate { /// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里 private func updateActivationPolicy() { - let hasWindow = mainWindow.isVisible || speedTestWindow.isVisible + let hasWindow = mainWindow.isVisible || egressWindow.isVisible || speedTestWindow.isVisible let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory guard NSApp.activationPolicy() != policy else { return } NSApp.setActivationPolicy(policy) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/EgressWindowController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/EgressWindowController.swift new file mode 100644 index 0000000..ce3dce8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/EgressWindowController.swift @@ -0,0 +1,53 @@ +import AppKit +import Localization +import SwiftUI + +/// 出口与分流窗口。打开期间检测器盯着网络环境,关闭后停止 +@MainActor +final class EgressWindowController: 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 + model.egress.windowDidOpen() + onVisibilityChange?(true) + } + NSApp.activate() + window.makeKeyAndOrderFront(nil) + } + + private func makeWindow() -> NSWindow { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: DS.Size.egressWindowWidth, height: DS.Size.egressWindowHeight), + 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: EgressWindowView().environment(model)) + // 第一次打开居中,之后沿用用户调整过的位置与大小 + if !window.setFrameUsingName("OpenStatsEgressWindow") { window.center() } + window.setFrameAutosaveName("OpenStatsEgressWindow") + return window + } + + func windowWillClose(_ notification: Notification) { + isVisible = false + model.egress.windowDidClose() + onVisibilityChange?(false) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index ec257ff..c6f5150 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -41,6 +41,8 @@ extension DS { /// 侧边栏选中项的淡蓝底 static let sidebarSelected = Color.dynamic(light: 0x2563EB, lightAlpha: 0.12, dark: 0x3B82F6, darkAlpha: 0.22) /// 全局加载时压暗整个窗口 + /// 网站图标的垫底:深色模式下也保持浅色,黑色字标才看得清 + static let logoTile = Color.dynamic(light: 0xFFFFFF, dark: 0xF3F4F6) static let scrim = Color.dynamic(light: 0x000000, lightAlpha: 0.12, dark: 0x000000, darkAlpha: 0.40) /// 核心类型配色:最高性能档为品牌蓝,其次浅蓝,能效核灰色 @@ -168,9 +170,14 @@ extension DS { static let panelGap: CGFloat = 4 /// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt static let windowHeader: CGFloat = DS.Space.s12 + DS.Space.s1 + /// 没有侧边栏的窗口里,顶栏标题要让出红绿灯按钮的宽度 + static let trafficLightsWidth: CGFloat = 80 /// 网络测速窗口 static let speedTestWindowWidth: CGFloat = 720 static let speedTestWindowHeight: CGFloat = 840 + /// 出口与分流窗口 + static let egressWindowWidth: CGFloat = 720 + static let egressWindowHeight: CGFloat = 800 /// 主窗口首次打开的最小高度(屏幕放得下时) static let windowDefaultHeight: CGFloat = 720 /// 主窗口默认内容区宽度(侧边栏另计) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/EgressWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/EgressWindowView.swift new file mode 100644 index 0000000..96040f3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/EgressWindowView.swift @@ -0,0 +1,771 @@ +import AppKit +import Localization +import Metrics +import SwiftUI + +/// 出口与分流窗口:总览(结论、统计、本机网络 → 代理出口的线路)、网站分流表、本机的代理方式 +struct EgressWindowView: View { + @Environment(AppModel.self) private var model + + var body: some View { + let egress = model.egress + VStack(spacing: 0) { + // 滚动视图会自动向上延伸到标题栏下面:顶栏垫上底色、放在它之上,滚动的内容才不会盖住按钮 + EgressHeader() + .background(DS.Palette.background) + .zIndex(1) + PageScroll { + if let report = egress.report, let analysis = egress.analysis { + let context = EgressContext(report: report, analysis: analysis) + OverviewCard(context: context) + SitesCard(context: context) + EnvironmentCard(environment: egress.environment ?? report.environment) + EgressFootnote() + } else { + FirstCheckCard() + } + } + } + .background(DS.Palette.background) + // 内容延伸到透明标题栏下方,顶栏与红绿灯按钮在同一行 + .ignoresSafeArea() + } +} + +/// 视图共用的查询:某个目标的测量、某个出口的归属地 +private struct EgressContext { + let report: EgressController.Report + let analysis: EgressAnalysis + + func hit(_ target: EgressTarget, _ route: EgressRoute) -> EgressHit? { + EgressAnalysis.hit(report.hits, target, route) + } + + func geo(_ ip: String?) -> EgressGeo? { + ip.flatMap { report.geo[$0] } + } + + /// 网站表里的条目(不含基准目标),按出口分组后的数量 + var siteCounts: (proxy: Int, direct: Int, unknown: Int, failed: Int) { + let siteIDs = Set(EgressTarget.sites.map(\.id)) + var counts = (proxy: 0, direct: 0, unknown: 0, failed: 0) + for exit in analysis.exits { + let count = exit.entries.filter { siteIDs.contains($0.targetID) }.count + switch exit.path { + case .proxy: counts.proxy += count + case .direct: counts.direct += count + case .unknown: counts.unknown += count + } + } + counts.unknown += analysis.unassignedTargetIDs.filter(siteIDs.contains).count + counts.failed = analysis.failedTargetIDs.filter(siteIDs.contains).count + return counts + } +} + +// MARK: - 顶栏 + +private struct EgressHeader: View { + @Environment(AppModel.self) private var model + + var body: some View { + let egress = model.egress + 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 let date = egress.report?.date, !egress.isChecking { + TimelineView(.periodic(from: .now, by: 30)) { context in + Text(verbatim: EgressText.checkedAt(date, now: context.date)) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + } + Button { egress.check() } label: { + HStack(spacing: DS.Space.s1) { + if egress.isChecking { + ProgressView().controlSize(.mini) + Text(tr("检测中 \(egress.progress)/\(EgressProber.jobCount)")).monospacedDigit() + } else { + Image(systemName: "arrow.clockwise") + Text(tr("重新检测")) + } + } + .lineLimit(1) + .fixedSize() + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(egress.isChecking) + } + // 让出红绿灯按钮,再隔开一段,标题不贴着最右边的按钮 + .padding(.leading, DS.Size.trafficLightsWidth + DS.Space.s4) + .padding(.trailing, DS.Space.s3 + DS.Space.s1) + .frame(height: DS.Size.windowHeader) + } +} + +private struct FirstCheckCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + Card(spacing: DS.Space.s3) { + Text(tr("正在检测出口与分流…")) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + ProgressView(value: Double(model.egress.progress), total: Double(EgressProber.jobCount)) + .tint(DS.Palette.primary) + Text(tr("分别经物理网卡、系统代理与 VPN 隧道访问检测目标,约需 10 秒。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + } +} + +// MARK: - 总览 + +private struct OverviewCard: View { + @Environment(AppModel.self) private var model + let context: EgressContext + + var body: some View { + let analysis = context.analysis + let summary = EgressText.verdict(analysis, report: context.report) + Card(spacing: DS.Space.s4) { + HStack(alignment: .top, spacing: DS.Space.s3) { + Image(systemName: summary.icon) + .font(.system(size: DS.TextSize.lg.rawValue, weight: .semibold)) + .foregroundStyle(summary.tone.color) + .frame(width: DS.Space.s8 + DS.Space.s2, height: DS.Space.s8 + DS.Space.s2) + .background(summary.tone.color.opacity(0.12), in: Circle()) + VStack(alignment: .leading, spacing: DS.Space.s1) { + Text(verbatim: summary.title) + .dsFont(.lg, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + Text(verbatim: summary.detail) + .dsFont(.sm) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: DS.Space.s4) + SiteStats(counts: context.siteCounts, verified: analysis.verdict != .unverified) + } + + HairlineDivider() + RouteDiagram(context: context) + + if model.egress.networkChanged || !analysis.findings.isEmpty { + VStack(alignment: .leading, spacing: DS.Space.s2) { + if model.egress.networkChanged { + InfoBanner(icon: "arrow.triangle.2.circlepath", text: tr("网络环境变了,下面是之前的结果,正在重新检测。"), tone: .primary) + } + ForEach(Array(analysis.findings.enumerated()), id: \.offset) { _, finding in + let banner = EgressText.finding(finding) + InfoBanner(icon: banner.icon, text: banner.text, tone: banner.tone) + } + } + } + } + } +} + +private struct SiteStats: View { + let counts: (proxy: Int, direct: Int, unknown: Int, failed: Int) + let verified: Bool + + var body: some View { + HStack(spacing: DS.Space.s4) { + if verified { + stat(tr("经代理"), counts.proxy, tone: .primary) + divider + stat(tr("直连"), counts.direct, tone: .neutral) + } else { + stat(tr("可访问"), counts.proxy + counts.direct + counts.unknown, tone: .neutral) + } + divider + stat(tr("连不上"), counts.failed, tone: counts.failed > 0 ? .error : .neutral) + } + .fixedSize() + } + + private var divider: some View { + Rectangle().fill(DS.Palette.border).frame(width: DS.Size.stroke, height: DS.Space.s8) + } + + private func stat(_ label: String, _ value: Int, tone: Tone) -> some View { + VStack(alignment: .trailing, spacing: 0) { + Text(verbatim: "\(value)") + .dsFont(.xl, weight: .semibold) + .foregroundStyle(tone == .neutral ? DS.Palette.textPrimary : tone.color) + .monospacedDigit() + Text(verbatim: label) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + } +} + +/// 本机网络 ──代理──▶ 代理出口。没有代理出口时只画本机网络 +private struct RouteDiagram: View { + let context: EgressContext + + var body: some View { + let analysis = context.analysis + let environment = context.report.environment + let rawV4 = context.hit(.cloudflare, .interface)?.ip ?? context.hit(.domestic, .interface)?.ip + let rawV6 = context.hit(.cloudflareIPv6, .interface)?.ip + let proxyExits = analysis.exits.filter { $0.path != .direct } + let primary = proxyExits.first + + HStack(alignment: .top, spacing: DS.Space.s4) { + ExitNode(caption: tr("本机网络"), + subtitle: environment.physicalName.map { tr("经 \($0) 直接连接") } ?? tr("不经过代理"), + ipv4: rawV4, ipv6: rawV6, geo: context.geo(rawV4) ?? context.geo(rawV6), + country: context.hit(.cloudflare, .interface)?.country, + missing: tr("VPN 不允许绕开它连接")) + if let primary { + RouteConnector(name: environment.tunnelName ?? environment.apps.first ?? tr("代理"), + detail: environment.tunnelInterface ?? (environment.proxies.isEmpty ? nil : tr("系统代理"))) + let systemV6 = context.hit(.cloudflareIPv6, .system)?.ip + ExitNode(caption: primary.path == .unknown ? tr("出口") : tr("代理出口"), + subtitle: proxyExits.count > 1 ? tr("另有 \(proxyExits.count - 1) 个出口,见网站分流") : tr("按系统代理访问国际网站"), + ipv4: primary.ip.contains(":") ? nil : primary.ip, + ipv6: primary.ip.contains(":") ? primary.ip : systemV6, + geo: context.geo(primary.ip), country: primary.country, missing: "—") + } + } + } +} + +private struct RouteConnector: View { + let name: String + let detail: String? + + var body: some View { + VStack(spacing: DS.Space.s1) { + Text(verbatim: name) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.primary) + .lineLimit(1) + HStack(spacing: 0) { + Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1 + DS.Space.s1 / 2, height: DS.Space.s1 + DS.Space.s1 / 2) + Rectangle().fill(DS.Palette.primary.opacity(0.5)).frame(height: DS.Size.stroke) + Image(systemName: "arrowtriangle.right.fill") + .font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1)) + .foregroundStyle(DS.Palette.primary) + } + if let detail { + Text(verbatim: detail) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + } + } + .frame(width: DS.Space.s16 + DS.Space.s12) + .padding(.top, DS.Space.s6) + } +} + +private struct ExitNode: View { + let caption: String + let subtitle: String + let ipv4: String? + let ipv6: String? + let geo: EgressGeo? + let country: String? + let missing: String + + var body: some View { + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(spacing: DS.Space.s1) { + Text(verbatim: caption).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textSecondary) + Text(verbatim: subtitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1) + } + if let address = ipv4 ?? ipv6 { + HStack(spacing: DS.Space.s2) { + if let code = geo?.countryCode ?? country { FlagImage(countryCode: code, height: DS.Space.s4) } + CopyableText(text: address) + .dsFont(.lg, weight: .semibold) + .monospacedDigit() + } + .padding(.top, DS.Space.s1) + let location = EgressText.location(geo, fallback: country) + if !location.isEmpty { + Text(verbatim: location).dsFont(.sm).foregroundStyle(DS.Palette.textPrimary).lineLimit(1) + } + HStack(spacing: DS.Space.s2) { + if let organization = geo?.organization { + Text(verbatim: organization) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + } + if let type = geo?.ipType.flatMap(EgressText.ipTypeBadge) { + TagBadge(text: type.text, tone: type.tone, compact: true) + } + } + if ipv4 != nil { + if let ipv6 { + HStack(spacing: DS.Space.s1) { + Text(verbatim: "IPv6").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textTertiary) + CopyableText(text: ipv6).dsFont(.xs).monospacedDigit() + } + } else { + Text(tr("不支持 IPv6")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + } else { + Text(verbatim: missing) + .dsFont(.sm) + .foregroundStyle(DS.Palette.textTertiary) + .padding(.top, DS.Space.s1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - 网站分流 + +private enum SiteFilter: Hashable, CaseIterable { + case all, ai, media, social, developer, domestic + + var title: String { + switch self { + case .all: tr("全部") + case .ai: "AI" + case .media: tr("影音") + case .social: tr("社交") + case .developer: tr("开发") + case .domestic: tr("国内") + } + } + + func includes(_ target: EgressTarget) -> Bool { + switch self { + case .all: true + case .ai: target.category == .ai + case .media: target.category == .media + case .social: target.category == .social + case .developer: target.category == .developer + case .domestic: target.category == .domestic + } + } +} + +private struct SitesCard: View { + let context: EgressContext + @State private var filter = SiteFilter.all + + var body: some View { + let groups = siteGroups + Card(padding: 0, spacing: 0) { + HStack(spacing: DS.Space.s3) { + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(tr("网站分流")).dsFont(.base, weight: .semibold).foregroundStyle(DS.Palette.textPrimary) + Text(tr("接入 Cloudflare 的网站能实测出口,其余按国内 / 国际规则推断")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + } + Spacer(minLength: DS.Space.s3) + SegmentedControl(selection: $filter, options: SiteFilter.allCases.map { ($0, $0.title) }) + .frame(width: DS.Size.sidebarWidth * 2) + } + .padding(DS.Space.s4) + + SiteColumnsHeader() + ForEach(groups) { group in + SiteGroupHeader(group: group, context: context) + ForEach(group.rows, id: \.target.id) { row in + SiteTableRow(target: row.target, hit: context.hit(row.target, .system), measured: row.measured, failed: group.kind == .failed) + } + } + if groups.isEmpty { + Text(tr("这一类没有检测结果")) + .dsFont(.sm) + .foregroundStyle(DS.Palette.textTertiary) + .frame(maxWidth: .infinity) + .padding(DS.Space.s6) + } + // 最后一行的悬停底色不贴到卡片圆角 + Color.clear.frame(height: DS.Space.s2) + } + } + + struct SiteGroup: Identifiable { + enum Kind: Equatable { case exit(EgressAnalysis.Exit), unassigned, failed } + let kind: Kind + let rows: [(target: EgressTarget, measured: Bool)] + var id: String { + switch kind { + case .exit(let exit): exit.ip + case .unassigned: "unassigned" + case .failed: "failed" + } + } + } + + private var siteGroups: [SiteGroup] { + let analysis = context.analysis + func target(_ id: String) -> EgressTarget? { + EgressTarget.sites.first { $0.id == id && filter.includes($0) } + } + var groups: [SiteGroup] = [] + for exit in analysis.exits { + let rows = exit.entries.compactMap { entry in target(entry.targetID).map { ($0, entry.measured) } } + if !rows.isEmpty { groups.append(SiteGroup(kind: .exit(exit), rows: rows)) } + } + let unassigned = analysis.unassignedTargetIDs.compactMap(target).map { ($0, false) } + if !unassigned.isEmpty { groups.append(SiteGroup(kind: .unassigned, rows: unassigned)) } + let failed = analysis.failedTargetIDs.compactMap(target).map { ($0, false) } + if !failed.isEmpty { groups.append(SiteGroup(kind: .failed, rows: failed)) } + return groups + } +} + +/// 列宽:出口依据、Cloudflare 节点、延迟 +private enum SiteColumn { + static let basis = DS.Size.valueColumn + static let colo = DS.Size.labelColumn + static let latency = DS.Size.valueColumn + DS.Space.s12 +} + +private struct SiteColumnsHeader: View { + var body: some View { + HStack(spacing: DS.Space.s3) { + Text(tr("网站")).frame(maxWidth: .infinity, alignment: .leading) + Text(tr("出口依据")).frame(width: SiteColumn.basis, alignment: .leading) + Text(tr("节点")).frame(width: SiteColumn.colo, alignment: .leading) + .help(tr("Cloudflare 接入节点(机场代码)")) + Text(tr("延迟")).frame(width: SiteColumn.latency, alignment: .trailing) + } + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textTertiary) + .padding(.horizontal, DS.Space.s4) + .padding(.bottom, DS.Space.s2) + } +} + +private struct SiteGroupHeader: View { + let group: SitesCard.SiteGroup + let context: EgressContext + + var body: some View { + HStack(spacing: DS.Space.s2) { + switch group.kind { + case .exit(let exit): + let badge = EgressText.pathBadge(exit.path) + let geo = context.geo(exit.ip) + TagBadge(icon: badge.icon, text: badge.text, tone: badge.tone, compact: true) + if let code = geo?.countryCode ?? exit.country { FlagImage(countryCode: code) } + CopyableText(text: exit.ip) + .dsFont(.xs, weight: .semibold) + .monospacedDigit() + let location = EgressText.location(geo, fallback: exit.country) + if !location.isEmpty { + Text(verbatim: location) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + } + case .unassigned: + TagBadge(icon: "questionmark.circle", text: tr("出口未知"), tone: .neutral, compact: true) + case .failed: + TagBadge(icon: "xmark.circle.fill", text: tr("连不上"), tone: .warning, compact: true) + } + Spacer(minLength: DS.Space.s2) + Text(tr("\(group.rows.count) 个网站")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize() + } + .padding(.horizontal, DS.Space.s4) + .padding(.vertical, DS.Space.s2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(DS.Palette.track) + } +} + +private struct SiteTableRow: View { + let target: EgressTarget + let hit: EgressHit? + let measured: Bool + let failed: Bool + @State private var hovering = false + + var body: some View { + HStack(spacing: DS.Space.s3) { + HStack(spacing: DS.Space.s3) { + SiteLogo(name: target.logo, fallback: target.name) + VStack(alignment: .leading, spacing: 0) { + Text(verbatim: target.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary).lineLimit(1) + Text(verbatim: target.host).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + if failed { + Text(verbatim: "—").foregroundStyle(DS.Palette.textTertiary) + } else if measured { + Label(tr("实测"), systemImage: "checkmark.circle.fill").foregroundStyle(DS.Palette.success) + } else { + Label(tr("推断"), systemImage: "circle.dashed").foregroundStyle(DS.Palette.textTertiary) + } + } + .labelStyle(CompactLabelStyle()) + .dsFont(.xs) + .frame(width: SiteColumn.basis, alignment: .leading) + .help(measured ? tr("读到了这次连接的出口") : failed ? "" : tr("网站没有接入 Cloudflare,读不到出口,按国内 / 国际规则归到对应出口")) + + Text(verbatim: hit?.colo ?? "—") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .monospaced() + .frame(width: SiteColumn.colo, alignment: .leading) + + LatencyCell(milliseconds: failed ? nil : hit?.milliseconds) + .frame(width: SiteColumn.latency, alignment: .trailing) + } + .padding(.horizontal, DS.Space.s4) + .padding(.vertical, DS.Space.s2) + .background(hovering ? DS.Palette.surfaceHover : .clear) + .contentShape(Rectangle()) + .onHover { hovering = $0 } + } +} + +private struct CompactLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: DS.Space.s1) { + configuration.icon + configuration.title + } + } +} + +/// 网站图标放在浅色圆角底上,深色模式下黑色字标也看得清;没有图标时显示首字 +private struct SiteLogo: View { + let name: String? + let fallback: String + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1 / 2, style: .continuous) + .fill(DS.Palette.logoTile) + .overlay(RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1 / 2, style: .continuous) + .strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)) + if let name, let image = LogoCache.shared.image(named: name, template: false) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + } else { + Text(verbatim: String(fallback.prefix(1))) + .dsFont(.xs, weight: .semibold) + .foregroundStyle(DS.Palette.neutral500) + } + } + .frame(width: DS.Space.s6 + DS.Space.s1, height: DS.Space.s6 + DS.Space.s1) + .accessibilityHidden(true) + } +} + +/// 5 格信号条 + 毫秒数:越快格子越多,颜色按快慢分绿、橙、红 +private struct LatencyCell: View { + let milliseconds: Int? + + var body: some View { + let tone = milliseconds.map(Self.tone) ?? .error + let filled = milliseconds.map(Self.bars) ?? 0 + HStack(spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s1 / 2) { + ForEach(0..<5, id: \.self) { index in + RoundedRectangle(cornerRadius: DS.Size.stroke) + .fill(index < filled ? tone.color : DS.Palette.track) + .frame(width: DS.Space.s1, height: DS.Space.s3) + } + } + Text(verbatim: milliseconds.map { "\($0) ms" } ?? tr("超时")) + .dsFont(.xs, weight: .medium) + .foregroundStyle(milliseconds == nil ? DS.Palette.error : DS.Palette.textPrimary) + .monospacedDigit() + .frame(width: DS.Size.valueColumn, alignment: .trailing) + } + } + + static func tone(_ milliseconds: Int) -> Tone { + milliseconds < 300 ? .success : milliseconds < 1000 ? .warning : .error + } + + static func bars(_ milliseconds: Int) -> Int { + switch milliseconds { + case ..<150: 5 + case ..<300: 4 + case ..<600: 3 + case ..<1000: 2 + default: 1 + } + } +} + +// MARK: - 代理方式 + +private struct EnvironmentCard: View { + let environment: ProxyEnvironment + + var body: some View { + SectionCard(title: tr("代理方式")) { + LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s8), GridItem(.flexible())], + alignment: .leading, spacing: DS.Space.s2) { + InfoRow(label: tr("VPN / 隧道"), + text: environment.tunnelName.map { name in + [name, environment.tunnelInterface].compactMap { $0 }.joined(separator: " · ") + } ?? tr("未使用")) + InfoRow(label: tr("系统代理"), text: systemProxy) + InfoRow(label: "DNS", text: dns) + InfoRow(label: tr("代理软件"), text: environment.apps.isEmpty ? tr("未发现") : environment.apps.joined(separator: tr("、"))) + } + } + } + + /// 同一地址的几种代理合成一条:HTTP、HTTPS 127.0.0.1:6152 + private var systemProxy: String { + var groups: [(address: String, kinds: [String])] = [] + for proxy in environment.proxies { + let address = "\(proxy.host):\(proxy.port)" + let kind = proxy.kind.rawValue.uppercased() + if let index = groups.firstIndex(where: { $0.address == address }) { + groups[index].kinds.append(kind) + } else { + groups.append((address, [kind])) + } + } + var parts = groups.map { "\($0.kinds.joined(separator: "/")) \($0.address)" } + if environment.pacURL != nil { parts.append(tr("自动代理配置")) } + return parts.isEmpty ? tr("未设置") : parts.joined(separator: "\n") + } + + private var dns: String { + guard !environment.dnsServers.isEmpty else { return "—" } + let servers = environment.dnsServers.joined(separator: ", ") + return environment.usesFakeIPDNS ? tr("\(servers)(代理接管)") : servers + } +} + +private struct EgressFootnote: View { + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) { + Text(tr("归属地数据来自")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + CleanIPLink(height: DS.Space.s3, url: URL(string: "https://cleanip.io/outbound")!, help: "cleanip.io/outbound") + Text(tr("· 结果反映 OpenStats 自己发出的请求,按应用分流的规则下其他应用可能走不同出口")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(2) + } + .padding(.horizontal, DS.Space.s1) + } +} + +// MARK: - 文案 + +@MainActor +private enum EgressText { + struct Summary { + let icon: String + let tone: Tone + let title: String + let detail: String + } + + static func verdict(_ analysis: EgressAnalysis, report: EgressController.Report) -> Summary { + let environment = report.environment + let proxyName = environment.tunnelName ?? environment.apps.first ?? tr("代理") + let proxyExit = analysis.exits.first { $0.path == .proxy } + let proxyLocation = proxyExit.map { location(report.geo[$0.ip], fallback: $0.country) } ?? "" + let exitText = proxyLocation.isEmpty ? tr("代理出口") : tr("代理出口(\(proxyLocation))") + + switch analysis.verdict { + case .offline: + return Summary(icon: "wifi.exclamationmark", tone: .error, title: tr("网络不通"), + detail: tr("所有检测目标都连不上。请检查网络连接,或者代理软件是否正常运行。")) + case .noProxy: + return Summary(icon: "checkmark", tone: .success, title: tr("未使用代理"), + detail: tr("没有检测到 VPN 或系统代理,所有流量都从本机网络直接出去。")) + case .proxyInactive: + return Summary(icon: "exclamationmark", tone: .warning, title: tr("代理没有生效"), + detail: tr("检测到 \(proxyName),但测试的网站都在直连,没有经过代理出口。")) + case .split: + return Summary(icon: "checkmark", tone: .success, title: tr("分流正常"), + detail: tr("国内网站直连,国际网站经 \(proxyName) 的\(exitText)。")) + case .mixed: + return Summary(icon: "arrow.triangle.branch", tone: .primary, title: tr("按规则分流"), + detail: tr("部分网站直连、部分经代理,每个网站走哪个出口见下方。")) + case .fullProxy where analysis.rawCountry == "CN": + return Summary(icon: "exclamationmark", tone: .warning, title: tr("全局代理"), + detail: tr("国内网站也绕到了\(exitText),访问会变慢,也可能触发风控。")) + case .fullProxy: + return Summary(icon: "checkmark", tone: .success, title: tr("全部经代理"), + detail: tr("测试的网站都经 \(proxyName) 的\(exitText),没有暴露本机网络的地址。")) + case .unverified: + return Summary(icon: "questionmark", tone: .primary, title: tr("无法确认是否经过代理"), + detail: tr("VPN 不允许绕开它连接,读不到本机网络原本的出口。下方按出口列出各网站。")) + } + } + + static func finding(_ finding: EgressAnalysis.Finding) -> (icon: String, text: String, tone: Tone) { + switch finding { + case .ipv6Leak(let address): + ("exclamationmark.triangle.fill", tr("IPv6 没有经过代理:只走 IPv6 的连接会直接暴露本机地址 \(address)。"), .warning) + case .ipv6Blocked: + ("checkmark.shield.fill", tr("代理出口不支持 IPv6,IPv6 连接也不会绕过代理泄露地址。"), .success) + case .ipv6Proxied: + ("checkmark.shield.fill", tr("IPv6 连接也经过代理。"), .success) + case .socketBypass: + ("exclamationmark.triangle.fill", + tr("只设置了系统代理:浏览器等应用走代理,命令行工具、游戏等不读代理设置的程序仍在直连。要接管全部流量,可以开启增强模式或 TUN 模式。"), .warning) + case .domesticProxied: + ("exclamationmark.triangle.fill", tr("国内网站走了代理,访问会绕路变慢。"), .warning) + } + } + + static func pathBadge(_ path: EgressAnalysis.Path) -> (icon: String, text: String, tone: TagBadge.Tone) { + switch path { + case .proxy: ("globe", tr("经代理"), .primary) + case .direct: ("house.fill", tr("直连"), .neutral) + case .unknown: ("circle.dashed", tr("出口"), .neutral) + } + } + + static func ipTypeBadge(_ raw: String) -> (text: String, tone: TagBadge.Tone)? { + let type = raw.lowercased() + if type.contains("residential") { return (tr("住宅 IP"), .success) } + if type.contains("mobile") { return (tr("移动网络 IP"), .success) } + if type.contains("business") { return (tr("企业 IP"), .success) } + if type.contains("idc") || type.contains("datacenter") || type.contains("hosting") { return (tr("机房 IP"), .warning) } + return nil + } + + /// 国家 · 省 / 州 · 城市;英文界面优先用英文地名,重复的相邻项只留一个 + static func location(_ geo: EgressGeo?, fallback country: String?) -> String { + guard let code = geo?.countryCode ?? country else { return "" } + let name = Locale(identifier: L10n.isEnglish ? "en" : "zh-Hans").localizedString(forRegionCode: code) ?? code + let region = L10n.isEnglish ? (geo?.regionEnglish ?? geo?.region) : geo?.region + let city = L10n.isEnglish ? (geo?.cityEnglish ?? geo?.city) : geo?.city + var parts = [name] + for part in [region, city].compactMap({ $0 }) where part != parts.last { parts.append(part) } + return parts.joined(separator: " · ") + } + + static func checkedAt(_ date: Date, now: Date) -> String { + let minutes = Int(now.timeIntervalSince(date) / 60) + return minutes < 1 ? tr("刚刚检测") : tr("\(Format.duration(minutes: minutes))前检测") + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift index a2af139..3e8f502 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/MainWindowView.swift @@ -236,6 +236,16 @@ private struct PageHeader: View { .buttonStyle(DSButtonStyle(kind: .secondary)) .help(tr("测本机宽带、国内分省三网延迟与全球节点")) } + if tab == .network { + Button { model.openEgressWindow() } label: { + HStack(spacing: DS.Space.s1) { + Image(systemName: "arrow.triangle.branch") + Text(tr("出口与分流")).lineLimit(1).fixedSize() + } + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .help(tr("检查 VPN 与代理是否生效、各网站从哪个出口出去")) + } // 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签 let items = tab.menuBarItems if !items.isEmpty { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift index 3d52c72..287fa9e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/NetworkPopover.swift @@ -273,6 +273,9 @@ private struct AddressSection: View { } } } + MiniIconButton(systemName: "arrow.triangle.branch", help: tr("出口与分流:检查 VPN 与代理是否生效、各网站从哪个出口出去")) { + model.openEgressWindow() + } } } @@ -524,11 +527,34 @@ private struct DNSSection: View { let manual = physical?.manualDNS ?? [] let matched = DNSPreset.matching(manual) - SectionCard(title: PopoverSection.networkDNS.title, trailing: { + // 走代理隧道时标题旁放一个叹号,悬停才显示说明;刷新缓存是图标按钮,放在标题行右侧 + let tunnelWarning = network.details?.tunnel.map { tunnel in + AnyView(Image(systemName: "exclamationmark.circle") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.warning) + .help(tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。")) + .accessibilityLabel(tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。"))) + } + // 网络服务与线路做成两个徽章:Wi-Fi / 有线,直连或经过哪个代理 + let actions = HStack(spacing: DS.Space.s2) { + if let physical { + TagBadge(icon: physical.wifi != nil ? "wifi" : "cable.connector", text: physical.serviceName, tone: .neutral, compact: true) + if let tunnel = network.details?.tunnel { + TagBadge(icon: "globe", text: tr("经 \(tunnel.name)"), tone: .primary, compact: true) + } else { + TagBadge(icon: "arrow.right", text: tr("直连"), tone: .neutral, compact: true) + } + } + RefreshButton(loading: maintenance.running == .flushDNS, help: tr("刷新 DNS 缓存")) { + Task { await maintenance.run(.flushDNS) } + } + .disabled(maintenance.running != nil) + } + SectionCard(title: PopoverSection.networkDNS.title, titleAccessory: tunnelWarning, trailing: { if isPopover { - Text(verbatim: physical?.serviceName ?? "").collapseButton(.networkDNS, settings: model.settings) - } else if let physical { - Text(verbatim: physical.serviceName) + actions.collapseButton(.networkDNS, settings: model.settings) + } else { + actions } }) { InfoRow(label: tr("正在使用")) { @@ -573,10 +599,6 @@ private struct DNSSection: View { InfoRow(label: tr("配置方式"), text: manual.isEmpty ? tr("自动(由路由器分配)") : matched?.title ?? tr("手动")) } - if let tunnel = network.details?.tunnel { - InfoBanner(icon: "info.circle", text: tr("流量经过 \(tunnel.name),系统 DNS 可能由它接管,修改后不一定生效。"), tone: .neutral) - } - if let physical { if editingManual { HStack(spacing: DS.Space.s2) { @@ -595,15 +617,6 @@ private struct DNSSection: View { } } - HStack(spacing: DS.Space.s2) { - Button(maintenance.running == .flushDNS ? tr("正在刷新…") : tr("刷新 DNS 缓存")) { - Task { await maintenance.run(.flushDNS) } - } - .buttonStyle(DSButtonStyle(kind: .secondary)) - .disabled(maintenance.running != nil) - Spacer(minLength: 0) - } - if maintenance.isApplyingDNS { Text(tr("正在修改 DNS…")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) } else if let outcome = maintenance.dnsOutcome ?? maintenance.outcomes[.flushDNS] { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-baidu.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-baidu.svg new file mode 100644 index 0000000..bb5ef67 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-baidu.svg @@ -0,0 +1 @@ +Baidu \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-bilibili.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-bilibili.svg new file mode 100644 index 0000000..12e09e2 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-bilibili.svg @@ -0,0 +1 @@ +Bilibili \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-claude.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-claude.svg new file mode 100644 index 0000000..29bb61b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-cloudflare.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-cloudflare.svg new file mode 100644 index 0000000..ac59cd6 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-cloudflare.svg @@ -0,0 +1,7 @@ + + Cloudflare + + + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-discord.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-discord.svg new file mode 100644 index 0000000..e6c391a --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-docker.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-docker.svg new file mode 100644 index 0000000..2e0703d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-docker.svg @@ -0,0 +1,3 @@ + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-douyin.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-douyin.svg new file mode 100644 index 0000000..337d28c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-douyin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-gemini.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-gemini.svg new file mode 100644 index 0000000..f8d7189 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-github.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-github.svg new file mode 100644 index 0000000..284350f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-github.svg @@ -0,0 +1,3 @@ + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-google.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-google.svg new file mode 100644 index 0000000..2f69df7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-google.svg @@ -0,0 +1,11 @@ + + Google + + + + + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-grok.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-grok.svg new file mode 100644 index 0000000..6f6057d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-grok.svg @@ -0,0 +1,4 @@ + + xAI + + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-instagram.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-instagram.svg new file mode 100644 index 0000000..e5a717f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-midjourney.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-midjourney.svg new file mode 100644 index 0000000..b68fbbf --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-midjourney.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-netflix.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-netflix.svg new file mode 100644 index 0000000..a737893 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-netflix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-npm.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-npm.svg new file mode 100644 index 0000000..cc69e90 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-npm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-openai.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-openai.svg new file mode 100644 index 0000000..2ebab67 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-perplexity.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-perplexity.svg new file mode 100644 index 0000000..42968a8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-reddit.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-reddit.svg new file mode 100644 index 0000000..488596c --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-reddit.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-taobao.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-taobao.svg new file mode 100644 index 0000000..9f88cc1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-taobao.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-telegram.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-telegram.svg new file mode 100644 index 0000000..2800caa --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-telegram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-tiktok.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-tiktok.svg new file mode 100644 index 0000000..57ce3ae --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-tiktok.svg @@ -0,0 +1 @@ +TikTok \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-twitch.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-twitch.svg new file mode 100644 index 0000000..d9b2fdb --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-twitch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-wechat.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-wechat.svg new file mode 100644 index 0000000..aa347e1 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-x.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-x.svg new file mode 100644 index 0000000..449fc9f --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-x.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-youtube.svg b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-youtube.svg new file mode 100644 index 0000000..1d03922 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Resources/Logos/site-youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 06ad477..efdd95f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -17,6 +17,7 @@ public final class AppModel { public let cleaner: CleanerController public let maintenance: MaintenanceController public let network: NetworkController + public let egress = EgressController() public let speedTest: SpeedTestController public let explainer = ProcessExplainer() public let updates: UpdateController @@ -40,6 +41,7 @@ public final class AppModel { @ObservationIgnored var openSettings: () -> Void = {} @ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in } + @ObservationIgnored var openEgressWindow: () -> Void = {} @ObservationIgnored var openSpeedTestWindow: () -> Void = {} @ObservationIgnored var quit: () -> Void = {} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/EgressController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/EgressController.swift new file mode 100644 index 0000000..a6c78e7 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/EgressController.swift @@ -0,0 +1,147 @@ +import AppKit +import Foundation +import Metrics +import Observation + +/// 出口与分流检测:只在窗口打开时工作。打开时没有结果、结果过期或网络变了就检测一次, +/// 窗口开着期间每隔几秒读一次代理环境(只读系统配置,不发请求),隧道、代理或网卡变化后自动重测 +@MainActor +@Observable +public final class EgressController { + public struct Report: Codable, Equatable, Sendable { + public var date: Date + public var environment: ProxyEnvironment + public var hits: [EgressHit] + /// 出口 IP → cleanip.io 给出的归属地 + public var geo: [String: EgressGeo] + } + + public private(set) var report: Report? + public private(set) var analysis: EgressAnalysis? + /// 实时读到的代理环境;与报告里的不同说明网络已经变了 + public private(set) var environment: ProxyEnvironment? + public private(set) var isChecking = false + /// 本次检测已完成的测量数,总数见 `EgressProber.jobCount` + public private(set) var progress = 0 + + @ObservationIgnored private var watchTask: Task? + @ObservationIgnored private var geoCache: [String: (geo: EgressGeo, date: Date)] = [:] + + private static let cacheKey = "egressReport2" + /// 超过这个时间的结果,打开窗口时重新检测 + static let staleAfter: TimeInterval = 10 * 60 + static let geoLifetime: TimeInterval = 60 * 60 + + init() { + if let data = UserDefaults.standard.data(forKey: Self.cacheKey), + let report = try? JSONDecoder().decode(Report.self, from: data) { + apply(report) + } + } + + /// 网络环境与上次检测时不同 + public var networkChanged: Bool { + guard let report, let environment else { return false } + return report.environment.signature != environment.signature + } + + func windowDidOpen() { + startWatching() + } + + func windowDidClose() { + watchTask?.cancel() + watchTask = nil + } + + func check() { + guard !isChecking else { return } + isChecking = true + progress = 0 + Task { + let environment = await Self.readEnvironment() + self.environment = environment + let hits = await EgressProber.measure(interfaceName: environment.physicalInterface) { done in + Task { @MainActor [weak self] in self?.progress = done } + } + let analysis = EgressAnalysis.evaluate(hits, environment: environment) + let geo = await lookUpGeo(hits: hits, analysis: analysis, interfaceName: environment.physicalInterface) + let report = Report(date: Date(), environment: environment, hits: hits, geo: geo) + apply(report) + UserDefaults.standard.set(try? JSONEncoder().encode(report), forKey: Self.cacheKey) + isChecking = false + } + } + + private func apply(_ report: Report) { + self.report = report + analysis = EgressAnalysis.evaluate(report.hits, environment: report.environment) + } + + private func startWatching() { + watchTask?.cancel() + watchTask = Task { [weak self] in + var first = true + while !Task.isCancelled { + let environment = await Self.readEnvironment() + guard !Task.isCancelled, let self else { return } + if environment != self.environment { self.environment = environment } + let stale = self.report.map { Date().timeIntervalSince($0.date) > Self.staleAfter } ?? true + // 刚打开:没有结果或过期了就测;开着期间:网络变了就重测 + if !self.isChecking, (first && stale) || self.networkChanged { + self.check() + } + first = false + try? await Task.sleep(for: .seconds(3)) + } + } + } + + private static func readEnvironment() async -> ProxyEnvironment { + let apps = NSWorkspace.shared.runningApplications.compactMap(\.localizedName) + return await Task.detached { + ProxyEnvironmentReader.read(details: NetworkDetailsReader.read(), runningAppNames: apps) + }.value + } + + // MARK: 归属地 + + /// 原始出口经物理网卡查,代理出口经系统代理查;cleanip.io 只查请求方自己,查到的就是那条路线的出口。 + /// 同一个 IP 一小时内不重复查 + private func lookUpGeo(hits: [EgressHit], analysis: EgressAnalysis, interfaceName: String?) async -> [String: EgressGeo] { + var wanted: [(route: EgressRoute, ipv6: Bool, expected: String)] = [] + for address in analysis.rawAddresses { + let ipv6 = address.contains(":") + if !wanted.contains(where: { $0.route == .interface && $0.ipv6 == ipv6 }) { + wanted.append((.interface, ipv6, address)) + } + } + if let proxied = analysis.exits.first(where: { $0.path != .direct }) { + wanted.append((.system, proxied.ip.contains(":"), proxied.ip)) + } + + var result: [String: EgressGeo] = [:] + let now = Date() + var lookups: [(route: EgressRoute, ipv6: Bool)] = [] + for item in wanted { + if let cached = geoCache[item.expected], now.timeIntervalSince(cached.date) < Self.geoLifetime { + result[item.expected] = cached.geo + } else { + lookups.append((item.route, item.ipv6)) + } + } + let found = await withTaskGroup(of: (ip: String, geo: EgressGeo)?.self) { group in + for lookup in lookups { + group.addTask { await EgressProber.geo(route: lookup.route, ipv6: lookup.ipv6, interfaceName: interfaceName) } + } + var found: [(ip: String, geo: EgressGeo)] = [] + for await item in group { if let item { found.append(item) } } + return found + } + for item in found { + result[item.ip] = item.geo + geoCache[item.ip] = (item.geo, now) + } + return result + } +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/EgressTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/EgressTests.swift new file mode 100644 index 0000000..db96bd7 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/MetricsTests/EgressTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing +@testable import Metrics + +@Suite("出口与分流判定") +struct EgressAnalysisTests { + private let raw = "144.124.192.33" + private let rawV6 = "2a05:45c2:4053:3001:acf9:d8c2:547b:a885" + private let proxy = "212.135.40.68" + private let tunnel = ProxyEnvironment(tunnelName: "Surge", tunnelInterface: "utun6") + + /// 原始出口、按系统代理访问国内回显与 Cloudflare、常用网站,各给一个出口;有地址的算连上了 + private func hits(domestic: String?, cloudflare: String?, sites: [String: String?] = [:], reachable: [String] = [], + socketV4: String? = nil, socketV6: String? = nil, rawV6: String? = nil, + rawV4: String? = "144.124.192.33") -> [EgressHit] { + var hits = [ + EgressHit(targetID: EgressTarget.domestic.id, route: .interface, ip: rawV4), + EgressHit(targetID: EgressTarget.cloudflare.id, route: .interface, ip: rawV4, country: rawV4 == nil ? nil : "UZ"), + EgressHit(targetID: EgressTarget.cloudflareIPv6.id, route: .interface, ip: rawV6), + EgressHit(targetID: EgressTarget.domestic.id, route: .system, ip: domestic), + EgressHit(targetID: EgressTarget.cloudflare.id, route: .system, ip: cloudflare), + EgressHit(targetID: EgressTarget.cloudflareIPv6.id, route: .system), + EgressHit(targetID: EgressTarget.cloudflare.id, route: .socket, ip: socketV4), + EgressHit(targetID: EgressTarget.cloudflareIPv6.id, route: .socket, ip: socketV6), + ] + for (id, ip) in sites.sorted(by: { $0.key < $1.key }) { + hits.append(EgressHit(targetID: id, route: .system, ip: ip)) + } + for id in reachable { + hits.append(EgressHit(targetID: id, route: .system, milliseconds: 80)) + } + return hits.map { hit in + var hit = hit + if hit.ip != nil { hit.milliseconds = 100 } + return hit + } + } + + @Test func splitWhenDomesticDirectAndInternationalProxied() { + let result = EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: proxy, sites: ["chatgpt.com": proxy]), environment: tunnel) + #expect(result.verdict == .split) + #expect(result.exits.map(\.ip) == [proxy, raw]) + #expect(result.exits.first?.entries.map(\.targetID) == [EgressTarget.cloudflare.id, "chatgpt.com"]) + } + + @Test func fullProxyAndMixed() { + let full = EgressAnalysis.evaluate(hits(domestic: proxy, cloudflare: proxy), environment: tunnel) + #expect(full.verdict == .fullProxy) + // 某个网站被规则放行直连 + let mixed = EgressAnalysis.evaluate(hits(domestic: proxy, cloudflare: proxy, sites: ["claude.ai": raw]), environment: tunnel) + #expect(mixed.verdict == .mixed) + #expect(mixed.directCount == 1 && mixed.proxyCount == 2) + } + + @Test func directOnlyDependsOnProxyConfiguration() { + #expect(EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: raw), environment: ProxyEnvironment()).verdict == .noProxy) + #expect(EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: raw), environment: tunnel).verdict == .proxyInactive) + } + + @Test func offlineAndUnverified() { + #expect(EgressAnalysis.evaluate(hits(domestic: nil, cloudflare: nil), environment: tunnel).verdict == .offline) + let blocked = EgressAnalysis.evaluate(hits(domestic: proxy, cloudflare: proxy, rawV4: nil), environment: tunnel) + #expect(blocked.verdict == .unverified) + #expect(blocked.exits.allSatisfy { $0.path == .unknown }) + } + + @Test func ipv6Findings() { + let leak = EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: proxy, socketV4: proxy, + socketV6: "2a05:45c2:4053:3001::1234", rawV6: rawV6), environment: tunnel) + #expect(leak.findings == [.ipv6Leak("2a05:45c2:4053:3001::1234")]) + let blocked = EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: proxy, socketV4: proxy, rawV6: rawV6), environment: tunnel) + #expect(blocked.findings == [.ipv6Blocked]) + let proxied = EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: proxy, socketV4: proxy, + socketV6: "2001:db8::1", rawV6: rawV6), environment: tunnel) + #expect(proxied.findings == [.ipv6Proxied]) + } + + @Test func systemProxyOnlyLetsOtherProgramsBypass() { + let result = EgressAnalysis.evaluate(hits(domestic: raw, cloudflare: proxy, socketV4: raw, socketV6: rawV6, rawV6: rawV6), + environment: ProxyEnvironment(proxies: [.init(kind: .http, host: "127.0.0.1", port: 6152)])) + #expect(result.findings == [.socketBypass]) + } + + @Test func infersExitsForSitesWithoutTrace() { + // YouTube 读不到出口,归到 Cloudflare 的出口;百度归到国内回显的出口;没连上的 Netflix 单列 + var input = hits(domestic: raw, cloudflare: proxy, reachable: ["www.youtube.com", "www.baidu.com"]) + input.append(EgressHit(targetID: "www.netflix.com", route: .system)) + let result = EgressAnalysis.evaluate(input, environment: tunnel) + #expect(result.verdict == .split) + let proxied = result.exits.first { $0.ip == proxy } + #expect(proxied?.entries.contains(.init(targetID: "www.youtube.com", measured: false)) == true) + let direct = result.exits.first { $0.ip == raw } + #expect(direct?.entries.contains(.init(targetID: "www.baidu.com", measured: false)) == true) + #expect(result.failedTargetIDs == ["www.netflix.com"]) + // 推断的不影响结论的计数 + #expect(result.proxyCount == 1 && result.directCount == 1) + } + + @Test func sameExitComparesIPv6Prefix() { + #expect(EgressAnalysis.sameExit("2a05:45c2:4053:3001::1", "2a05:45c2:4053:3001:acf9::2")) + #expect(!EgressAnalysis.sameExit("2a05:45c2:4053:3002::1", "2a05:45c2:4053:3001::1")) + #expect(!EgressAnalysis.sameExit("1.2.3.4", "1.2.3.5")) + } +} + +@Suite("代理环境") +struct ProxyEnvironmentTests { + @Test func parsesSystemProxies() { + let settings: [String: Any] = [ + "HTTPEnable": 1, "HTTPProxy": "127.0.0.1", "HTTPPort": 6152, + "HTTPSEnable": 1, "HTTPSProxy": "127.0.0.1", "HTTPSPort": 6152, + "SOCKSEnable": 0, "SOCKSProxy": "127.0.0.1", "SOCKSPort": 6153, + "ProxyAutoConfigEnable": 0, + ] + let (proxies, pac) = ProxyEnvironmentReader.parseProxies(settings) + #expect(proxies.map(\.kind) == [.http, .https]) + #expect(proxies.first?.port == 6152) + #expect(pac == nil) + } + + @Test func detectsFakeIPAndApps() { + #expect(ProxyEnvironment(dnsServers: ["198.18.0.2"]).usesFakeIPDNS) + #expect(ProxyEnvironment(dnsServers: ["198.19.255.1"]).usesFakeIPDNS) + #expect(!ProxyEnvironment(dnsServers: ["198.20.0.1", "1.1.1.1"]).usesFakeIPDNS) + #expect(ProxyEnvironmentReader.proxyApps(in: ["Safari", "Surge", "Clash Verge", "Surgeon"]) == ["Surge", "Clash Verge"]) + } + + @Test func parsesDoHAnswer() { + let json = #"{"Status":0,"Answer":[{"name":"cleanip.io","type":5,"data":"x.example"},{"name":"cleanip.io","type":1,"data":"104.26.11.201"}]}"# + #expect(EgressProber.parseDoH(Data(json.utf8), ipv6: false) == "104.26.11.201") + #expect(EgressProber.parseDoH(Data(json.utf8), ipv6: true) == nil) + } +} diff --git a/README.md b/README.md index 1ea320e..31dbd6d 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,26 @@ system language by default; switch it in Settings → General → Language. -Latest release **0.5.0** (2026-09-18) · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.5.0** (2026-09-18) · **6** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
+2026-09-18 · Unreleased · 5 added · 1 style + +**Added** + +- 新增出口与分流窗口:网络页顶栏和菜单栏网络弹窗各有一个入口。一次检测约十秒,看清 VPN 与代理有没有生效、各个网站分别从哪个出口出去。 +- 一句话结论:分“未使用代理”“代理出口(地区)”“代理没有生效”“网络不通”四种情况,右侧给出经代理、直连、连不上的网站数量。 +- 本机网络:显示物理网卡的出口 IP、归属地、运营商与 IPv6,归属地数据来自 cleanip.io。 +- 网站分流:ChatGPT、Claude、YouTube、GitHub、哔哩哔哩等 25 个常用网站,按 AI、影音、社交、开发、国内分类筛选。接入 Cloudflare 的网站能实测出口与边缘节点,其余按国内 / 国际规则推断,每个网站带延迟信号条。 +- 代理方式:列出 VPN / 隧道、系统代理、自动代理配置、DNS(被代理接管时会标出)与检测到的代理软件。 + +**Style** + +- 菜单栏网络弹窗的 DNS 区块重排:网络服务与线路做成两个徽章(Wi-Fi / 有线、直连或经过哪个代理),走隧道时标题旁有叹号提示 DNS 可能被接管,刷新 DNS 缓存改为图标按钮。 + +
+ +
2026-09-18 · 0.5.0 · 6 added **Added** @@ -84,33 +101,6 @@ Latest release **0.5.0** (2026-09-18) · [full changelog](CHANGELOG.md) (kept in
-
-2026-09-16 · 0.4.0 · 1 fixed · 2 changed · 6 style · 1 added - -**Fixed** - -- 修复网络弹窗偶尔露出一小截滚动条:以前弹窗打开时按当时的内容算高度,之后公网 IP、归属地等数据陆续到达,内容变高了窗口却不跟着变。现在内容一变高或变矮,窗口高度随之调整到正好放下,只有屏幕放不下时才出现滚动条;展开、收起区块时同样适用。 - -**Changed** - -- 网络弹窗只显示公网 IP,没有 IPv6 时不占行:本地 IPv4、本地 IPv6 与路由器只在主窗口的网络页显示;没有 IPv6 时不再显示“—”占一行,网卡没有 IPv6 地址就不显示“本地 IPv6”,拿不到公网 IPv6(网络或代理不支持)就不显示“公网 IPv6”。 -- 应用的 CPU 占用改为占整机的比例:CPU 弹窗与主窗口 CPU 页的“按应用汇总”、仪表盘的“高占用进程”,全部核心跑满为 100%,和顶部的总占用是同一把尺子。以前按单核满载为 100% 计(活动监视器的算法),多核 Mac 上一个应用动不动显示 99%、75%,容易误以为很吃 CPU;鼠标悬停在数值上仍可看到单核口径的数值。进程页保持单核口径,与活动监视器一致。 - -**Style** - -- 主窗口在 macOS 26 上改用液态玻璃:侧边栏是一块浮在窗口里的玻璃面板,选中项垫一块淡蓝色圆角底并随切换滑动;顶栏右侧“在菜单栏显示”一组收进玻璃胶囊。主按钮是蓝色玻璃胶囊、次要按钮与图标按钮是透明玻璃,风扇模式等快捷切换是玻璃胶囊,开关换成系统开关(拖动时滑块变成玻璃)。设置分组与数据卡片保持实色,与系统设置一致。macOS 14、15 上保持原来的样式。 -- 菜单栏弹窗改为紧凑排版,参照 Stats:区块不再是灰底卡片,改成一条细分隔线隔开,行距收紧,顶栏按钮缩小;网络弹窗的 IP 纯净度与 DNS 默认收起成一行(纯净度显示分数与等级,DNS 显示正在使用的地址),点一下展开、标题栏右侧的箭头收起,状态会记住;流量图变矮,连接探测改为两行细格。网络弹窗从约 1520pt 降到约 950pt,其他弹窗矮了一到两成。主窗口的详情页保持原样,照常完整显示。 -- IP 纯净度的标题行重新排版:置信度不再是带框的绿色徽章,改成标题旁一行浅灰小字,百分比按高低着色;“查看完整报告”文字链接换成外链图标,与刷新、收起并成一组按钮,macOS 26 上合成一颗玻璃胶囊。分数右侧的 cleanip.io 字标照旧可以打开完整报告。 -- 连接探测只用来看网络通不通:格子通了是绿色、不通是红色(不再用橙色表示延迟偏高),固定 60 格,弹窗排成 3 行、主窗口排成 2 行;去掉格子下面的延迟、抖动、丢包三项,标题右侧也不再写探测地址与频率。打开网络详情时固定每秒探测一次,「设置 · 网络」里的“探测间隔”选项随之去掉,后台低频探测仍是每 10 秒。 -- 主窗口顶栏加高,右上角按钮高度统一:顶栏改为标准高度(52pt,原来 40pt),右上角的按钮、“在菜单栏显示”开关组与“防休眠已开启”标记统一为 32pt 高,上下各留 10pt,各页高度一致,不再偏上。macOS 26 上红绿灯按钮留在侧边栏玻璃面板之外,面板从顶栏下方开始、与右侧内容卡片顶边对齐,顶栏那一条可以按住拖动窗口。 -- 分段切换改为苹果标准样式:灰色圆角底槽,各段等宽铺满,选中的一段是实心蓝色、白字,切换时蓝块滑过去;网络详情的 IPv4 / IPv6 切换同样是灰底蓝选中。 - -**Added** - -- 清理、卸载等耗时操作进行中显示全局加载框:清理、卸载应用、导出诊断信息、修改 DNS 进行中,整个主窗口压暗,中间浮一块加载框写明正在做什么(macOS 26 上是液态玻璃),期间不能误点其他操作,完成后自动消失。 - -
- ## Activity diff --git a/README.zh-CN.md b/README.zh-CN.md index 0538f76..39c7521 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,9 +55,26 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.5.0**(2026-09-18) · [完整更新日志](CHANGELOG.md) +最新版本 **0.5.0**(2026-09-18) · 开发中 **6** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
+2026-09-18 · 未发布 · 新增 5 · 样式 1 + +**新增** + +- 新增出口与分流窗口:网络页顶栏和菜单栏网络弹窗各有一个入口。一次检测约十秒,看清 VPN 与代理有没有生效、各个网站分别从哪个出口出去。 +- 一句话结论:分“未使用代理”“代理出口(地区)”“代理没有生效”“网络不通”四种情况,右侧给出经代理、直连、连不上的网站数量。 +- 本机网络:显示物理网卡的出口 IP、归属地、运营商与 IPv6,归属地数据来自 cleanip.io。 +- 网站分流:ChatGPT、Claude、YouTube、GitHub、哔哩哔哩等 25 个常用网站,按 AI、影音、社交、开发、国内分类筛选。接入 Cloudflare 的网站能实测出口与边缘节点,其余按国内 / 国际规则推断,每个网站带延迟信号条。 +- 代理方式:列出 VPN / 隧道、系统代理、自动代理配置、DNS(被代理接管时会标出)与检测到的代理软件。 + +**样式** + +- 菜单栏网络弹窗的 DNS 区块重排:网络服务与线路做成两个徽章(Wi-Fi / 有线、直连或经过哪个代理),走隧道时标题旁有叹号提示 DNS 可能被接管,刷新 DNS 缓存改为图标按钮。 + +
+ +
2026-09-18 · 0.5.0 · 新增 6 **新增** @@ -80,33 +97,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
-
-2026-09-16 · 0.4.0 · 修复 1 · 调整 2 · 样式 6 · 新增 1 - -**修复** - -- 修复网络弹窗偶尔露出一小截滚动条:以前弹窗打开时按当时的内容算高度,之后公网 IP、归属地等数据陆续到达,内容变高了窗口却不跟着变。现在内容一变高或变矮,窗口高度随之调整到正好放下,只有屏幕放不下时才出现滚动条;展开、收起区块时同样适用。 - -**调整** - -- 网络弹窗只显示公网 IP,没有 IPv6 时不占行:本地 IPv4、本地 IPv6 与路由器只在主窗口的网络页显示;没有 IPv6 时不再显示“—”占一行,网卡没有 IPv6 地址就不显示“本地 IPv6”,拿不到公网 IPv6(网络或代理不支持)就不显示“公网 IPv6”。 -- 应用的 CPU 占用改为占整机的比例:CPU 弹窗与主窗口 CPU 页的“按应用汇总”、仪表盘的“高占用进程”,全部核心跑满为 100%,和顶部的总占用是同一把尺子。以前按单核满载为 100% 计(活动监视器的算法),多核 Mac 上一个应用动不动显示 99%、75%,容易误以为很吃 CPU;鼠标悬停在数值上仍可看到单核口径的数值。进程页保持单核口径,与活动监视器一致。 - -**样式** - -- 主窗口在 macOS 26 上改用液态玻璃:侧边栏是一块浮在窗口里的玻璃面板,选中项垫一块淡蓝色圆角底并随切换滑动;顶栏右侧“在菜单栏显示”一组收进玻璃胶囊。主按钮是蓝色玻璃胶囊、次要按钮与图标按钮是透明玻璃,风扇模式等快捷切换是玻璃胶囊,开关换成系统开关(拖动时滑块变成玻璃)。设置分组与数据卡片保持实色,与系统设置一致。macOS 14、15 上保持原来的样式。 -- 菜单栏弹窗改为紧凑排版,参照 Stats:区块不再是灰底卡片,改成一条细分隔线隔开,行距收紧,顶栏按钮缩小;网络弹窗的 IP 纯净度与 DNS 默认收起成一行(纯净度显示分数与等级,DNS 显示正在使用的地址),点一下展开、标题栏右侧的箭头收起,状态会记住;流量图变矮,连接探测改为两行细格。网络弹窗从约 1520pt 降到约 950pt,其他弹窗矮了一到两成。主窗口的详情页保持原样,照常完整显示。 -- IP 纯净度的标题行重新排版:置信度不再是带框的绿色徽章,改成标题旁一行浅灰小字,百分比按高低着色;“查看完整报告”文字链接换成外链图标,与刷新、收起并成一组按钮,macOS 26 上合成一颗玻璃胶囊。分数右侧的 cleanip.io 字标照旧可以打开完整报告。 -- 连接探测只用来看网络通不通:格子通了是绿色、不通是红色(不再用橙色表示延迟偏高),固定 60 格,弹窗排成 3 行、主窗口排成 2 行;去掉格子下面的延迟、抖动、丢包三项,标题右侧也不再写探测地址与频率。打开网络详情时固定每秒探测一次,「设置 · 网络」里的“探测间隔”选项随之去掉,后台低频探测仍是每 10 秒。 -- 主窗口顶栏加高,右上角按钮高度统一:顶栏改为标准高度(52pt,原来 40pt),右上角的按钮、“在菜单栏显示”开关组与“防休眠已开启”标记统一为 32pt 高,上下各留 10pt,各页高度一致,不再偏上。macOS 26 上红绿灯按钮留在侧边栏玻璃面板之外,面板从顶栏下方开始、与右侧内容卡片顶边对齐,顶栏那一条可以按住拖动窗口。 -- 分段切换改为苹果标准样式:灰色圆角底槽,各段等宽铺满,选中的一段是实心蓝色、白字,切换时蓝块滑过去;网络详情的 IPv4 / IPv6 切换同样是灰底蓝选中。 - -**新增** - -- 清理、卸载等耗时操作进行中显示全局加载框:清理、卸载应用、导出诊断信息、修改 DNS 进行中,整个主窗口压暗,中间浮一块加载框写明正在做什么(macOS 26 上是液态玻璃),期间不能误点其他操作,完成后自动消失。 - -
- ## 活跃度 diff --git a/project.yml b/project.yml index 34674a2..c3c903b 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: SWIFT_VERSION: "6.0" MARKETING_VERSION: "0.5.0" # 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改 - CURRENT_PROJECT_VERSION: "0098" + CURRENT_PROJECT_VERSION: "0099" MACOSX_DEPLOYMENT_TARGET: "14.0" # 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md) CODE_SIGN_STYLE: Manual