新增出口与分流窗口:检测 VPN 与代理是否生效,25 个网站分别从哪个出口出去

This commit is contained in:
gentpan committed 2026-09-18 00:57:49 +05:00
1 parent 17b319153e
commit 80f7639f21
44 files changed
+2067 -81

No files matched your search

@@ -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
@@ -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)
}
@@ -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)) } }
}
}
@@ -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
}
@@ -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
}
}
@@ -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)
}
@@ -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)
@@ -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)
}
}
@@ -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
/// 主窗口默认内容区宽度(侧边栏另计)
@@ -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))前检测")
}
}
@@ -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 {
@@ -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] {
@@ -0,0 +1 @@
<svg fill="#2932E1" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M9.154 0C7.71 0 6.54 1.658 6.54 3.707c0 2.051 1.171 3.71 2.615 3.71 1.446 0 2.614-1.659 2.614-3.71C11.768 1.658 10.6 0 9.154 0zm7.025.594C14.86.58 13.347 2.589 13.2 3.927c-.187 1.745.25 3.487 2.179 3.735 1.933.25 3.175-1.806 3.422-3.364.252-1.555-.995-3.364-2.362-3.674a1.218 1.218 0 0 0-.261-.03zM3.582 5.535a2.811 2.811 0 0 0-.156.008c-2.118.19-2.428 3.24-2.428 3.24-.287 1.41.686 4.425 3.297 3.864 2.617-.561 2.262-3.68 2.183-4.362-.125-1.018-1.292-2.773-2.896-2.75zm16.534 1.753c-2.308 0-2.617 2.119-2.617 3.616 0 1.43.121 3.425 2.988 3.362 2.867-.063 2.553-3.238 2.553-3.988 0-.745-.62-2.99-2.924-2.99zm-8.264 2.478c-1.424.014-2.708.925-3.323 1.947-1.118 1.868-2.863 3.05-3.112 3.363-.25.309-3.61 2.116-2.864 5.42.746 3.301 3.365 3.237 3.365 3.237s1.93.19 4.171-.31c2.24-.495 4.17.123 4.17.123s5.233 1.748 6.665-1.616c1.43-3.364-.808-5.109-.808-5.109s-2.99-2.306-4.736-4.798c-1.072-1.665-2.348-2.268-3.528-2.257zm-2.234 3.84l1.542.024v8.197H7.758c-1.47-.291-2.055-1.292-2.13-1.462-.072-.173-.488-.976-.268-2.343.635-2.049 2.447-2.196 2.447-2.196h1.81zm3.964 2.39v3.881c.096.413.612.488.612.488h1.614v-4.343h1.689v5.782h-3.915c-1.517-.39-1.59-1.465-1.59-1.465v-4.317zm-5.458 1.147c-.66.197-.978.708-1.05.928-.076.22-.247.78-.1 1.269.294 1.095 1.248 1.144 1.248 1.144h1.37v-3.34z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg fill="#00A1D6" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg fill="#D97757" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Cloudflare</title>
<path d="M16.493 17.4c.135-.52.08-.983-.161-1.338-.215-.328-.592-.519-1.05-.519l-8.663-.109a.148.148 0 01-.135-.082c-.027-.054-.027-.109-.027-.163.027-.082.108-.164.189-.164l8.744-.11c1.05-.054 2.153-.9 2.556-1.937l.511-1.31c.027-.055.027-.11.027-.164C17.92 8.91 15.66 7 12.942 7c-2.503 0-4.628 1.638-5.381 3.903a2.432 2.432 0 00-1.803-.491c-1.21.109-2.153 1.092-2.287 2.32-.027.328 0 .628.054.9C1.56 13.688 0 15.326 0 17.319c0 .19.027.355.027.545 0 .082.08.137.161.137h15.983c.08 0 .188-.055.215-.164l.107-.437"
fill="#F38020" />
<path d="M19.238 11.75h-.242c-.054 0-.108.054-.135.109l-.35 1.2c-.134.52-.08.983.162 1.338.215.328.592.518 1.05.518l1.855.11c.054 0 .108.027.135.082.027.054.027.109.027.163-.027.082-.108.164-.188.164l-1.91.11c-1.05.054-2.153.9-2.557 1.937l-.134.355c-.027.055.026.137.107.137h6.592c.081 0 .162-.055.162-.137.107-.41.188-.846.188-1.31-.027-2.62-2.153-4.777-4.762-4.777"
fill="#FCAD32" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 256 199" width="256" height="199" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z" fill="#5865F2"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#008fe2">
<path d="M13.98 11.08h2.12a.19.19 0 0 0 .19-.19V9.01a.19.19 0 0 0-.19-.19h-2.12a.18.18 0 0 0-.18.18v1.9c0 .1.08.18.18.18m-2.95-5.43h2.12a.19.19 0 0 0 .18-.19V3.57a.19.19 0 0 0-.18-.18h-2.12a.18.18 0 0 0-.19.18v1.9c0 .1.09.18.19.18m0 2.71h2.12a.19.19 0 0 0 .18-.18V6.29a.19.19 0 0 0-.18-.18h-2.12a.18.18 0 0 0-.19.18v1.89c0 .1.09.18.19.18m-2.93 0h2.12a.19.19 0 0 0 .18-.18V6.29a.18.18 0 0 0-.18-.18H8.1a.18.18 0 0 0-.18.18v1.89c0 .1.08.18.18.18m-2.96 0h2.11a.19.19 0 0 0 .19-.18V6.29a.18.18 0 0 0-.19-.18H5.14a.19.19 0 0 0-.19.18v1.89c0 .1.08.18.19.18m5.89 2.72h2.12a.19.19 0 0 0 .18-.19V9.01a.19.19 0 0 0-.18-.19h-2.12a.18.18 0 0 0-.19.18v1.9c0 .1.09.18.19.18m-2.93 0h2.12a.18.18 0 0 0 .18-.19V9.01a.18.18 0 0 0-.18-.19H8.1a.18.18 0 0 0-.18.18v1.9c0 .1.08.18.18.18m-2.96 0h2.11a.18.18 0 0 0 .19-.19V9.01a.18.18 0 0 0-.18-.19H5.14a.19.19 0 0 0-.19.19v1.88c0 .1.08.19.19.19m-2.92 0h2.12a.18.18 0 0 0 .18-.19V9.01a.18.18 0 0 0-.18-.19H2.22a.18.18 0 0 0-.19.18v1.9c0 .1.08.18.19.18m21.54-1.19c-.06-.05-.67-.51-1.95-.51-.34 0-.68.03-1.01.09a3.77 3.77 0 0 0-1.72-2.57l-.34-.2-.23.33a4.6 4.6 0 0 0-.6 1.43c-.24.97-.1 1.88.4 2.66a4.7 4.7 0 0 1-1.75.42H.76a.75.75 0 0 0-.76.75 11.38 11.38 0 0 0 .7 4.06 6.03 6.03 0 0 0 2.4 3.12c1.18.73 3.1 1.14 5.28 1.14.98 0 1.96-.08 2.93-.26a12.25 12.25 0 0 0 3.82-1.4 10.5 10.5 0 0 0 2.61-2.13c1.25-1.42 2-3 2.55-4.4h.23c1.37 0 2.21-.55 2.68-1 .3-.3.55-.66.7-1.06l.1-.28Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779491596415" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14354" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M866.304 0H157.696C71.168 0 0 69.632 0 156.16v711.168c0 87.04 71.168 156.672 157.696 156.672h709.12c86.528 0 157.184-69.632 157.696-156.16v-711.68c-1.024-86.528-71.68-156.16-158.208-156.16z m-24.064 444.416c-63.488 0.512-126.976-19.456-179.2-56.32 0 80.384 0 160.768 1.024 241.152 0 10.752 0.512 21.504-0.512 32.256-2.56 39.424-15.36 77.312-34.816 111.616-16.896 29.184-39.936 55.808-67.584 75.776-35.328 26.112-79.36 40.96-123.392 41.984-22.528 0.512-45.056-0.512-66.56-5.632-30.72-6.656-60.416-19.456-86.016-37.376-0.512-0.512-1.536-1.024-1.536-1.536-27.648-19.456-50.688-44.544-67.584-73.728-23.04-38.4-34.816-83.968-33.28-129.024 1.024-41.472 13.312-82.432 34.816-117.76C237.056 493.568 264.704 465.92 296.96 446.464c46.592-28.672 103.424-40.96 157.696-33.28v30.72c0.512 12.8 0 25.6 0.512 37.888v65.024c-16.384-5.632-34.304-5.632-51.2-1.536-19.968 4.608-39.424 13.312-55.296 26.624-9.728 8.192-17.92 17.92-23.552 29.184-9.728 18.944-13.312 40.96-11.264 62.464 2.56 20.48 11.264 40.448 24.576 56.32 9.216 10.752 20.48 19.968 32.768 27.136 33.28 18.432 76.288 16.896 108.544-3.584 31.232-19.456 51.2-54.784 51.2-91.136V322.56c0.512-62.976 0-125.952 0.512-188.928h130.048c-0.512 11.264 1.024 22.016 3.072 33.28h0.512c3.584 18.432 10.24 35.84 18.944 52.736 13.312 25.6 32.768 48.64 57.344 63.488 1.536 1.024 2.56 2.56 4.096 3.584 18.432 11.264 38.4 19.968 59.392 24.064 12.288 2.56 25.088 3.584 37.376 4.096V444.416z" fill="#000000" p-id="14355"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 296 298" xmlns="http://www.w3.org/2000/svg" width="296" height="298" fill="none"><mask id="a" width="296" height="298" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#3186FF" d="M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="163" cy="149" fill="#3689FF" rx="196" ry="159"/></g><g filter="url(#c)"><ellipse cx="33.5" cy="142.5" fill="#F6C013" rx="68.5" ry="72.5"/></g><g filter="url(#d)"><ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5"/></g><g filter="url(#e)"><path fill="#FA4340" d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z"/></g><g filter="url(#f)"><path fill="#FA4340" d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z"/></g><g filter="url(#g)"><path fill="#14BB69" d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z"/></g><g filter="url(#h)"><path fill="#14BB69" d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z"/></g></g><defs><filter id="b" width="464" height="390" x="-69" y="-46" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="18"/></filter><filter id="c" width="265" height="273" x="-99" y="6" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="d" width="265" height="273" x="-113" y="12" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="e" width="299.5" height="329" x="-41.5" y="-130" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="f" width="299.5" height="329" x="-45" y="-153" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="g" width="299.5" height="329" x="-41" y="91" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="h" width="299.5" height="329" x="-39" y="132" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" transform="scale(64)" fill="#181717"/>
</svg>

After

Width:  |  Height:  |  Size: 941 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Google</title>
<path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z"
fill="#4285F4" />
<path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z"
fill="#34A853" />
<path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z"
fill="#FBBC05" />
<path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z"
fill="#EB4335" />
</svg>

After

Width:  |  Height:  |  Size: 891 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
<title>xAI</title>
<path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 264.583 264.583"><defs><radialGradient xlink:href="#a" id="f" cx="158.429" cy="578.088" r="52.352" fx="158.429" fy="578.088" gradientTransform="matrix(0 -4.03418 4.28018 0 -2332.227 942.236)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#b" id="g" cx="172.615" cy="600.692" r="65" fx="172.615" fy="600.692" gradientTransform="matrix(.67441 -1.16203 1.51283 .87801 -814.366 -47.835)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#c" id="h" cx="144.012" cy="51.337" r="67.081" fx="144.012" fy="51.337" gradientTransform="matrix(-2.3989 .67549 -.23008 -.81732 464.996 -26.404)" gradientUnits="userSpaceOnUse"/><radialGradient xlink:href="#d" id="e" cx="199.788" cy="628.438" r="52.352" fx="199.788" fy="628.438" gradientTransform="matrix(-3.10797 .87652 -.6315 -2.23914 1345.65 1374.198)" gradientUnits="userSpaceOnUse"/><linearGradient id="d"><stop offset="0" stop-color="#ff005f"/><stop offset="1" stop-color="#fc01d8"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#780cff"/><stop offset="1" stop-color="#820bff" stop-opacity="0"/></linearGradient><linearGradient id="b"><stop offset="0" stop-color="#fc0"/><stop offset="1" stop-color="#fc0" stop-opacity="0"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#fc0"/><stop offset=".124" stop-color="#fc0"/><stop offset=".567" stop-color="#fe4a05"/><stop offset=".694" stop-color="#ff0f3f"/><stop offset="1" stop-color="#fe0657" stop-opacity="0"/></linearGradient></defs><path fill="url(#e)" d="M204.15 18.143c-55.23 0-71.383.057-74.523.317-11.334.943-18.387 2.728-26.07 6.554-5.922 2.942-10.592 6.351-15.201 11.13-8.394 8.716-13.481 19.439-15.323 32.184-.895 6.188-1.156 7.45-1.209 39.056-.02 10.536 0 24.4 0 42.999 0 55.2.062 71.341.326 74.476.916 11.032 2.645 17.973 6.308 25.565 7 14.533 20.37 25.443 36.12 29.514 5.453 1.404 11.476 2.178 19.208 2.544 3.277.142 36.669.244 70.081.244 33.413 0 66.826-.04 70.02-.203 8.954-.422 14.153-1.12 19.901-2.606 15.852-4.09 28.977-14.838 36.12-29.575 3.591-7.409 5.412-14.614 6.236-25.07.18-2.28.255-38.626.255-74.924 0-36.304-.082-72.583-.26-74.863-.835-10.625-2.656-17.77-6.364-25.32-3.042-6.182-6.42-10.799-11.324-15.519-8.752-8.361-19.455-13.45-32.21-15.29-6.18-.894-7.41-1.158-39.033-1.213z" transform="translate(-71.816 -18.143)"/><path fill="url(#f)" d="M204.15 18.143c-55.23 0-71.383.057-74.523.317-11.334.943-18.387 2.728-26.07 6.554-5.922 2.942-10.592 6.351-15.201 11.13-8.394 8.716-13.481 19.439-15.323 32.184-.895 6.188-1.156 7.45-1.209 39.056-.02 10.536 0 24.4 0 42.999 0 55.2.062 71.341.326 74.476.916 11.032 2.645 17.973 6.308 25.565 7 14.533 20.37 25.443 36.12 29.514 5.453 1.404 11.476 2.178 19.208 2.544 3.277.142 36.669.244 70.081.244 33.413 0 66.826-.04 70.02-.203 8.954-.422 14.153-1.12 19.901-2.606 15.852-4.09 28.977-14.838 36.12-29.575 3.591-7.409 5.412-14.614 6.236-25.07.18-2.28.255-38.626.255-74.924 0-36.304-.082-72.583-.26-74.863-.835-10.625-2.656-17.77-6.364-25.32-3.042-6.182-6.42-10.799-11.324-15.519-8.752-8.361-19.455-13.45-32.21-15.29-6.18-.894-7.41-1.158-39.033-1.213z" transform="translate(-71.816 -18.143)"/><path fill="url(#g)" d="M204.15 18.143c-55.23 0-71.383.057-74.523.317-11.334.943-18.387 2.728-26.07 6.554-5.922 2.942-10.592 6.351-15.201 11.13-8.394 8.716-13.481 19.439-15.323 32.184-.895 6.188-1.156 7.45-1.209 39.056-.02 10.536 0 24.4 0 42.999 0 55.2.062 71.341.326 74.476.916 11.032 2.645 17.973 6.308 25.565 7 14.533 20.37 25.443 36.12 29.514 5.453 1.404 11.476 2.178 19.208 2.544 3.277.142 36.669.244 70.081.244 33.413 0 66.826-.04 70.02-.203 8.954-.422 14.153-1.12 19.901-2.606 15.852-4.09 28.977-14.838 36.12-29.575 3.591-7.409 5.412-14.614 6.236-25.07.18-2.28.255-38.626.255-74.924 0-36.304-.082-72.583-.26-74.863-.835-10.625-2.656-17.77-6.364-25.32-3.042-6.182-6.42-10.799-11.324-15.519-8.752-8.361-19.455-13.45-32.21-15.29-6.18-.894-7.41-1.158-39.033-1.213z" transform="translate(-71.816 -18.143)"/><path fill="url(#h)" d="M204.15 18.143c-55.23 0-71.383.057-74.523.317-11.334.943-18.387 2.728-26.07 6.554-5.922 2.942-10.592 6.351-15.201 11.13-8.394 8.716-13.481 19.439-15.323 32.184-.895 6.188-1.156 7.45-1.209 39.056-.02 10.536 0 24.4 0 42.999 0 55.2.062 71.341.326 74.476.916 11.032 2.645 17.973 6.308 25.565 7 14.533 20.37 25.443 36.12 29.514 5.453 1.404 11.476 2.178 19.208 2.544 3.277.142 36.669.244 70.081.244 33.413 0 66.826-.04 70.02-.203 8.954-.422 14.153-1.12 19.901-2.606 15.852-4.09 28.977-14.838 36.12-29.575 3.591-7.409 5.412-14.614 6.236-25.07.18-2.28.255-38.626.255-74.924 0-36.304-.082-72.583-.26-74.863-.835-10.625-2.656-17.77-6.364-25.32-3.042-6.182-6.42-10.799-11.324-15.519-8.752-8.361-19.455-13.45-32.21-15.29-6.18-.894-7.41-1.158-39.033-1.213z" transform="translate(-71.816 -18.143)"/><path fill="#fff" d="M132.345 33.973c-26.716 0-30.07.117-40.563.594-10.472.48-17.62 2.136-23.876 4.567-6.47 2.51-11.958 5.87-17.426 11.335-5.472 5.464-8.834 10.948-11.354 17.4Line truncated

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,6 @@
<svg width="698" height="583" viewBox="0 0 698 583" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 573.5C29 573.5 59 531.5 94 525.5C114 525.5 129 567.5 179 573.5C214 573.5 229 531.5 264 531.5C299 531.5 314 573.5 349 573.5C384 573.5 399 531.5 434 531.5C469 531.5 484 573.5 519 573.5C554 573.5 569 531.5 604 531.5C639 531.5 654 573.5 689 573.5" stroke="black" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M77.4 531.7L54.5 487.9L644.5 449.9C598.1 492.1 538.5 526.3 478.2 554.3" stroke="black" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M289.4 79.9004C389.8 110.6 530.2 258.9 578 418.3C551.8 408 532.2 397.5 495.4 406.9C459.8 277.4 396.1 153.7 289.4 79.9004Z" stroke="black" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M102.7 9.00049C231.3 64.0005 407.7 217.1 440.1 421C291.8 361.2 178.9 393.1 100.3 441.6C220.2 289.2 166.4 115.9 102.7 9.00049Z" stroke="black" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Netflix_Symbol_RGB" version="1.1" viewBox="0 0 551.111 1000"><defs id="defs4"><linearGradient id="linearGradient35887"><stop id="stop35883" offset="0" style="stop-color:#b1060f;stop-opacity:1"/><stop id="stop36053" offset=".625" style="stop-color:#7b010c;stop-opacity:1"/><stop id="stop35885" offset="1" style="stop-color:#b1060f;stop-opacity:0"/></linearGradient><linearGradient id="linearGradient19332"><stop id="stop19328" offset="0" style="stop-color:#b1060f;stop-opacity:1"/><stop id="stop19560" offset=".546" style="stop-color:#7b010c;stop-opacity:1"/><stop id="stop19330" offset="1" style="stop-color:#e50914;stop-opacity:0"/></linearGradient><linearGradient xlink:href="#linearGradient19332" id="linearGradient13368" x1="78.234" x2="221.663" y1="423.767" y2="365.092" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#linearGradient35887" id="linearGradient35889" x1="456.365" x2="309.676" y1="521.56" y2="583.495" gradientUnits="userSpaceOnUse"/><style id="style2">.cls-1{fill:#e50914}</style></defs><path id="path6055" d="M-1.152-1.152 2.305 1002.67c73.273-14.111 130.892-12.569 195.924-18.44V0Z" style="fill:url(#linearGradient13368);stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"/><path id="path678" d="M353.816 0h199.381l2.305 1000.365-202.839-33.422z" style="fill:url(#linearGradient35889);stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"/><path id="path362" d="M1.152 0c4.61 11.525 345.749 981.925 345.749 981.925 56.056-.4 131.219 8.754 205.144 17.288L197.077 0Z" style="fill:#e50914;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 2500 2500" xmlns="http://www.w3.org/2000/svg" width="2500" height="2500"><path fill="#c00" d="M0 0h2500v2500H0z"/><path fill="#fff" d="M1241.5 268.5h-973v1962.9h972.9V763.5h495v1467.9h495V268.5z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="260" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg fill="#1FB8CD" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Perplexity</title><path d="M22.3977 7.0896h-2.3106V.0676l-7.5094 6.3542V.1577h-1.1554v6.1966L4.4904 0v7.0896H1.6023v10.3976h2.8882V24l6.932-6.3591v6.2005h1.1554v-6.0469l6.9318 6.1807v-6.4879h2.8882V7.0896zm-3.4657-4.531v4.531h-5.355l5.355-4.531zm-13.2862.0676 4.8691 4.4634H5.6458V2.6262zM2.7576 16.332V8.245h7.8476l-6.1149 6.1147v1.9723H2.7576zm2.8882 5.0404v-3.8852h.0001v-2.6488l5.7763-5.7764v7.0111l-5.7764 5.2993zm12.7086.0248-5.7766-5.1509V9.0618l5.7766 5.7766v6.5588zm2.8882-5.0652h-1.733v-1.9723L13.3948 8.245h7.8478v8.087z"/></svg>

After

Width:  |  Height:  |  Size: 633 B

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="_1O4jTk-dZ-VIxsCuYB6OR8" viewBox="0 0 216 216">
<defs>
<radialGradient id="snoo-radial-gragient" cx="169.75" cy="92.19" r="50.98" fx="169.75" fy="92.19" gradientTransform="matrix(1 0 0 .87 0 11.64)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#feffff"/>
<stop offset=".4" stop-color="#feffff"/>
<stop offset=".51" stop-color="#f9fcfc"/>
<stop offset=".62" stop-color="#edf3f5"/>
<stop offset=".7" stop-color="#dee9ec"/>
<stop offset=".72" stop-color="#d8e4e8"/>
<stop offset=".76" stop-color="#ccd8df"/>
<stop offset=".8" stop-color="#c8d5dd"/>
<stop offset=".83" stop-color="#ccd6de"/>
<stop offset=".85" stop-color="#d8dbe2"/>
<stop offset=".88" stop-color="#ede3e9"/>
<stop offset=".9" stop-color="#ffebef"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-2" cx="47.31" r="50.98" fx="47.31"/>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-3" cx="109.61" cy="85.59" r="153.78" fx="109.61" fy="85.59" gradientTransform="matrix(1 0 0 .7 0 25.56)"/>
<radialGradient id="snoo-radial-gragient-4" cx="-6.01" cy="64.68" r="12.85" fx="-6.01" fy="64.68" gradientTransform="matrix(1.07 0 0 1.55 81.08 27.26)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f60"/>
<stop offset=".5" stop-color="#ff4500"/>
<stop offset=".7" stop-color="#fc4301"/>
<stop offset=".82" stop-color="#f43f07"/>
<stop offset=".92" stop-color="#e53812"/>
<stop offset="1" stop-color="#d4301f"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient-4" id="snoo-radial-gragient-5" cx="-73.55" cy="64.68" r="12.85" fx="-73.55" fy="64.68" gradientTransform="matrix(-1.07 0 0 1.55 62.87 27.26)"/>
<radialGradient id="snoo-radial-gragient-6" cx="107.93" cy="166.96" r="45.3" fx="107.93" fy="166.96" gradientTransform="matrix(1 0 0 .66 0 57.4)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#172e35"/>
<stop offset=".29" stop-color="#0e1c21"/>
<stop offset=".73" stop-color="#030708"/>
<stop offset="1"/>
</radialGradient>
<radialGradient xlink:href="#snoo-radial-gragient" id="snoo-radial-gragient-7" cx="147.88" cy="32.94" r="39.77" fx="147.88" fy="32.94" gradientTransform="matrix(1 0 0 .98 0 .54)"/>
<radialGradient id="snoo-radial-gragient-8" cx="131.31" cy="73.08" r="32.6" fx="131.31" fy="73.08" gradientUnits="userSpaceOnUse">
<stop offset=".48" stop-color="#7a9299"/>
<stop offset=".67" stop-color="#172e35"/>
<stop offset=".75"/>
<stop offset=".82" stop-color="#172e35"/>
</radialGradient>
<style>
.snoo-cls-11{stroke-width:0;fill:#ffc49c}
</style>
</defs>
<path fill="#ff4500" stroke-width="0" d="M108 0C48.35 0 0 48.35 0 108c0 29.82 12.09 56.82 31.63 76.37l-20.57 20.57C6.98 209.02 9.87 216 15.64 216H108c59.65 0 108-48.35 108-108S167.65 0 108 0Z"/>
<circle cx="169.22" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient)" stroke-width="0"/>
<circle cx="46.78" cy="106.98" r="25.22" fill="url(#snoo-radial-gragient-2)" stroke-width="0"/>
<ellipse cx="108.06" cy="128.64" fill="url(#snoo-radial-gragient-3)" stroke-width="0" rx="72" ry="54"/>
<path fill="url(#snoo-radial-gragient-4)" stroke-width="0" d="M86.78 123.48c-.42 9.08-6.49 12.38-13.56 12.38s-12.46-4.93-12.04-14.01c.42-9.08 6.49-15.02 13.56-15.02s12.46 7.58 12.04 16.66Z"/>
<path fill="url(#snoo-radial-gragient-5)" stroke-width="0" d="M129.35 123.48c.42 9.08 6.49 12.38 13.56 12.38s12.46-4.93 12.04-14.01c-.42-9.08-6.49-15.02-13.56-15.02s-12.46 7.58-12.04 16.66Z"/>
<ellipse cx="79.63" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
<ellipse cx="146.21" cy="116.37" class="snoo-cls-11" rx="2.8" ry="3.05"/>
<path fill="url(#snoo-radial-gragient-6)" stroke-width="0" d="M108.06 142.92c-8.76 0-17.16.43-24.92 1.22-1.33.13-2.17 1.51-1.65 2.74 4.35 10.39 14.61 17.69 26.57 17.69s22.23-7.3 26.57-17.69c.52-1.23-.33-2.61-1.65-2.74-7.77-.79-16.16-1.22-24.92-1.22Z"/>
<circle cx="147.49" cy="49.43" r="17.87" fill="url(#snoo-radial-gragient-7)" stroke-width="0"/>
<path fill="url(#snoo-radial-gragient-8)" stroke-width="0" d="M107.8 76.92c-2.14 0-3.87-.89-3.87-2.27 0-16.01 13.03-29.04 29.04-29.04 2.14 0 3.87 1.73 3.87 3.87s-1.73 3.87-3.87 3.87c-11.74 0-21.29 9.55-21.29 21.29 0 1.38-1.73 2.27-3.87 2.27Z"/>
<path fill="#842123" stroke-width="0" d="M62.82 122.65c.39-8.56 6.08-14.16 12.69-14.16 6.26 0 11.1 6.39 11.28 14.33.17-8.88-5.13-15.99-12.05-15.99s-13.14 6.05-13.56 15.2c-.42 9.15 4.97 13.83 12.04 13.83h.52c-6.44-.16-11.3-4.79-10.91-13.2Zm90.48 0c-.39-8.56-6.08-14.16-12.69-14.16-6.26 0-11.1 6.39-11.28 14.33-.17-8.88 5.13-15.99 12.05-15.99 7.07 0 13.14 6.05 13.56 15.2.42 9.15-4.97 13.83-12.04 13.83h-.52c6.44-.16 11.3-4.79 10.91-13.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779491247382" class="icon" viewBox="0 0 1032 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6323" xmlns:xlink="http://www.w3.org/1999/xlink" width="201.5625" height="200"><path d="M849.92 51.2H174.08c-67.8656 0-122.88 55.0144-122.88 122.88v675.84c0 67.8656 55.0144 122.88 122.88 122.88h675.84c67.8656 0 122.88-55.0144 122.88-122.88V174.08c0-67.8656-55.0144-122.88-122.88-122.88zM311.31136 284.88704c30.53568 0 55.24992 22.25664 55.24992 49.536 0 27.264-24.71424 49.49504-55.24992 49.49504-30.4896 0-55.19872-22.22592-55.19872-49.49504 0-27.27424 24.71424-49.536 55.19872-49.536z m46.08 297.40544c-14.57152 45.14816-10.95168 28.40576-68.80256 154.07616L204.8 684.11904s93.44-85.04832 112.68096-123.88864c20.29056-41.00608-22.44096-62.84288-22.44096-62.84288l-64.16896-40.00768 34.6624-53.4528c48.42496 35.93728 52.04992 38.95808 84.736 71.67488 25.2416 25.344 22.06208 60.544 7.12192 106.69056z m455.50592 58.64448c-16.33792 153.28256-215.76704 96.35328-215.76704 96.35328l10.81344-43.89888 46.16192 9.94816c85.4784 5.43232 77.13792-69.24288 77.13792-69.24288V417.08544c0.7168-82.37056-77.48096-90.9312-217.78944-40.73984l32.6912 8.82688c-2.87232 9.6512-13.4912 24.65792-26.9824 41.14432h187.72992v37.95968h-105.68192v47.52896h105.22624v37.95968h-105.22112v79.6672c15.70304-5.2736 30.44864-12.3904 43.21792-21.85728l-9.32864-34.75968 49.73056-15.43168 41.27232 101.3248-61.12768 25.41056-10.88-40.3968c-27.2384 20.92544-84.0704 51.14368-183.11168 48.47616-105.89184 2.70848-78.7456-119.38816-78.7456-119.38816l2.70848-1.41824h74.40896c-0.4864 15.7696-6.89664 41.34912 1.90976 55.27552 7.14752 11.42784 25.8048 13.4144 37.67808 13.94688 1.32096 0.1792 2.7392 0.1792 4.12672 0.2304v-91.07968H405.0944v-37.95968h107.9808v-47.53408h-27.7248c-24.30464 25.7792-46.4384 47.11936-46.4384 47.11936l-32.5888-28.50304c23.14752-24.34048 46.30528-62.85312 60.54912-88.66304a1370.25536 1370.25536 0 0 0-35.88608 15.22688 554.7264 554.7264 0 0 1-41.28256 46.29504c0.56832 0.86016-53.24288-30.14656-53.24288-30.14656 55.61856-47.5136 86.8096-149.22752 86.8096-149.22752l77.4144 21.62688s-6.50752 15.08864-19.6352 38.11328c309.74976-87.7056 327.72096 53.77024 327.72096 53.77024s20.40832 97.72544 4.12672 251.05408z" fill="#FF5000" p-id="6324"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><defs><linearGradient id="a" x1="50%" x2="50%" y1="0%" y2="100%"><stop offset="0%" stop-color="#2AABEE"/><stop offset="100%" stop-color="#229ED9"/></linearGradient></defs><path fill="url(#a)" d="M128 0C94.06 0 61.48 13.494 37.5 37.49A128.038 128.038 0 0 0 0 128c0 33.934 13.5 66.514 37.5 90.51C61.48 242.506 94.06 256 128 256s66.52-13.494 90.5-37.49c24-23.996 37.5-56.576 37.5-90.51 0-33.934-13.5-66.514-37.5-90.51C194.52 13.494 161.94 0 128 0Z"/><path fill="#FFF" d="M57.94 126.648c37.32-16.256 62.2-26.974 74.64-32.152 35.56-14.786 42.94-17.354 47.76-17.441 1.06-.017 3.42.245 4.96 1.49 1.28 1.05 1.64 2.47 1.82 3.467.16.996.38 3.266.2 5.038-1.92 20.24-10.26 69.356-14.5 92.026-1.78 9.592-5.32 12.808-8.74 13.122-7.44.684-13.08-4.912-20.28-9.63-11.26-7.386-17.62-11.982-28.56-19.188-12.64-8.328-4.44-12.906 2.76-20.386 1.88-1.958 34.64-31.748 35.26-34.45.08-.338.16-1.598-.6-2.262-.74-.666-1.84-.438-2.64-.258-1.14.256-19.12 12.152-54 35.686-5.1 3.508-9.72 5.218-13.88 5.128-4.56-.098-13.36-2.584-19.9-4.708-8-2.606-14.38-3.984-13.82-8.41.28-2.304 3.46-4.662 9.52-7.072Z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TikTok</title><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>

After

Width:  |  Height:  |  Size: 707 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 2400 2800"><path fill="#fff" d="m2200 1300-400 400h-400l-350 350v-350H600V200h1600z"/><g fill="#9146ff"><path d="M500 0 0 500v1800h600v500l500-500h400l900-900V0H500zm1700 1300-400 400h-400l-350 350v-350H600V200h1600v1100z"/><path d="M1700 550h200v600h-200zm-550 0h200v600h-200z"/></g></svg>

After

Width:  |  Height:  |  Size: 364 B

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779491253426" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7351" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1010.8 628c0-141.2-141.3-256.2-299.9-256.2-168 0-300.3 115.1-300.3 256.2 0 141.4 132.3 256.2 300.3 256.2 35.2 0 70.7-8.9 106-17.7l96.8 53-26.6-88.2c70.9-53.2 123.7-123.7 123.7-203.3zM618 588.8c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40c0 22-17.9 40-40 40z m194.3-0.3c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-17.9 40-40 40z" fill="#00C800" p-id="7352"></path><path d="M366.3 106.9c-194.1 0-353.1 132.3-353.1 300.3 0 97 52.9 176.6 141.3 238.4l-35.3 106.2 123.4-61.9c44.2 8.7 79.6 17.7 123.7 17.7 11.1 0 22.1-0.5 33-1.4-6.9-23.6-10.9-48.3-10.9-74 0-154.3 132.5-279.5 300.2-279.5 11.5 0 22.8 0.8 34 2.1C692 212.6 539.9 106.9 366.3 106.9zM247.7 349.2c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48z m246.6 0c-26.5 0-48-21.5-48-48s21.5-48 48-48 48 21.5 48 48-21.5 48-48 48z" fill="#00C800" p-id="7353"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>X</title><path d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"/></svg>

After

Width:  |  Height:  |  Size: 314 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0 0 256 180"><path fill="red" d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134Z"/><path fill="#FFF" d="m102.421 128.06 66.328-38.418-66.328-38.418z"/></svg>

After

Width:  |  Height:  |  Size: 516 B

@@ -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 = {}
@@ -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<Void, Never>?
@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
}
}
@@ -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)
}
}