mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 13:50:47 +08:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f204245a | ||
|
|
24ae883604 | ||
|
|
5e327df437 | ||
|
|
cf29b714d3 | ||
|
|
8ef6f0cc23 | ||
|
|
7d8f98e183 | ||
|
|
ef179fcf61 | ||
|
|
4b3bc54b2d | ||
|
|
10dbe60671 | ||
|
|
07a1515d8c | ||
|
|
5cbb0b6cfd | ||
|
|
3de4d53b8f | ||
|
|
f69d03c4d4 | ||
|
|
b98c9be7db | ||
|
|
7e0cf4f9f6 | ||
|
|
98d70978e4 | ||
|
|
7fb5001bea | ||
|
|
593ac07f03 | ||
|
|
382cbf9fb5 | ||
|
|
3d8d7731e1 | ||
|
|
5590f6ab15 | ||
|
|
cefcb150c9 | ||
|
|
b71a1e4ec5 | ||
|
|
3515c79e2b | ||
|
|
95bd5ceed9 | ||
|
|
dca6fdcae6 | ||
|
|
2f7e5771ac | ||
|
|
912f93fe85 | ||
|
|
1cf6e2c6bd | ||
|
|
aa6237e86b | ||
|
|
f41dea9622 | ||
|
|
c90de1355e | ||
|
|
5a2f1fc4c7 | ||
|
|
389c5342e5 | ||
|
|
df66d976a5 | ||
|
|
85b5501faf | ||
|
|
2fc831de27 | ||
|
|
ece3c71f38 | ||
|
|
f86a591597 | ||
|
|
e599a9d02c | ||
|
|
7796259b03 | ||
|
|
91d0643b5c | ||
|
|
52bbf07f1f | ||
|
|
4e9524898d | ||
|
|
bce83d5c6f | ||
|
|
82c486923b | ||
|
|
23dda47641 | ||
|
|
c079da6c99 | ||
|
|
e9cd49eb46 | ||
|
|
0a19dd965d | ||
|
|
444215f8ed | ||
|
|
17fe92352e | ||
|
|
2c8b1a0607 | ||
|
|
b08bcfedb8 | ||
|
|
ccf21f4a31 | ||
|
|
b57668243b | ||
|
|
6219452168 | ||
|
|
304f5bade9 | ||
|
|
fd70d4c62b | ||
|
|
d7dbff9f08 | ||
|
|
c85928ecaf | ||
|
|
e63b8e6eb0 | ||
|
|
971dd37965 |
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.74.8": {
|
||||
"github/gh-aw-actions/setup@v0.83.4": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.74.8",
|
||||
"sha": "efa55847f72aadb03490d955263ff911bf758700"
|
||||
"version": "v0.83.4",
|
||||
"sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
|
||||
},
|
||||
"github/gh-aw/actions/setup@v0.74.8": {
|
||||
"repo": "github/gh-aw/actions/setup",
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.96.1'
|
||||
toolchain: '1.97.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.96.1'
|
||||
toolchain: '1.97.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -279,7 +279,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
@@ -526,7 +526,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Install Node
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
@@ -78,6 +78,10 @@ jobs:
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Report unused frontend code and dependencies
|
||||
- name: Run tests
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:report
|
||||
run: pnpm test
|
||||
|
||||
- name: Run Knip
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:check
|
||||
|
||||
@@ -78,6 +78,11 @@ jobs:
|
||||
working-directory: ./src-tauri
|
||||
run: cargo clippy-all
|
||||
|
||||
- name: Run Windows security tests
|
||||
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
|
||||
working-directory: ./src-tauri
|
||||
run: cargo test --all-features --target ${{ matrix.target }}
|
||||
|
||||
- name: Run Logging Check
|
||||
working-directory: ./src-tauri
|
||||
shell: bash
|
||||
|
||||
+432
-185
File diff suppressed because one or more lines are too long
@@ -205,7 +205,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.96.1'
|
||||
toolchain: '1.97.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.96.1'
|
||||
toolchain: '1.97.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -331,7 +331,7 @@ jobs:
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -449,7 +449,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.96.1'
|
||||
toolchain: '1.97.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -467,7 +467,7 @@ jobs:
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -554,7 +554,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -580,7 +580,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -642,7 +642,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.18.0'
|
||||
|
||||
|
||||
Generated
+352
-209
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -56,9 +56,9 @@ tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sys
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.103"
|
||||
anyhow = "1.0.104"
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
tokio = { version = "1.52.3", features = [
|
||||
tokio = { version = "1.53.1", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
@@ -68,17 +68,22 @@ flexi_logger = "0.31.9"
|
||||
log = "0.4.33"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
compact_str = { version = "0.9.1", features = ["serde"] }
|
||||
compact_str = { version = "0.10.0", features = ["serde"] }
|
||||
|
||||
serde = { version = "1.0.228" }
|
||||
serde_json = { version = "1.0.150" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.13.0" }
|
||||
bitflags = { version = "2.13.1" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
# *** For Windows platform only ***
|
||||
deelevate = "0.2.0"
|
||||
windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects"
|
||||
] }
|
||||
|
||||
+14
-68
@@ -1,83 +1,29 @@
|
||||
## v2.5.2
|
||||
## v2.5.3
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复 macOS 托盘速率显示样式异常
|
||||
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
|
||||
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
|
||||
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
|
||||
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
|
||||
- 修复前端连接页面导致的内存泄漏
|
||||
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
|
||||
- 修复首页代理卡片可能错误显示通信异常的问题
|
||||
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
|
||||
- 修复 DNS 覆写的高级模式无法正常编辑
|
||||
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
|
||||
- 修复 Linux 应用内更新问题
|
||||
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
|
||||
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
|
||||
- 修复全局热键开关设置可能被意外覆盖的问题
|
||||
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
|
||||
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
|
||||
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
|
||||
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
|
||||
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
|
||||
- 修复 macOS 托盘图标可能闪烁的问题
|
||||
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
|
||||
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
|
||||
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
|
||||
- 修复首页上传和下载总流量统计不准确的问题
|
||||
- 修复首页当前代理状态偶尔不同步的问题
|
||||
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
|
||||
- 修复切换代理模式失败时未正确显示错误的问题
|
||||
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
|
||||
- 修复全局快捷键隐藏主窗口行为异常的问题
|
||||
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
|
||||
- 修复日志刷新时页面可能被强制滚动到底部的问题
|
||||
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
|
||||
- 修复代理组筛选后定位及滚动位置恢复异常的问题
|
||||
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
|
||||
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
|
||||
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
|
||||
- 修复内核启动慢导致首页上下跳动的问题
|
||||
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
|
||||
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
|
||||
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
|
||||
修复 macOS 多显示器切换时托盘速率样式不同步
|
||||
修复 WebSocket 在 onConnected 初始化完成前被保存的问题,避免初始化失败后留下无监听且无法重试的无效连接。
|
||||
修复 Mihomo API 变更后 inline proxy-provider 被错误过滤的问题,解决节点显示 Unknown 及延迟测试失败的问题
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.29**
|
||||
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
|
||||
- 全局扩展脚本增加恢复默认按钮
|
||||
- DNS 添加 fake-ip-range6 可配置项
|
||||
- Linux 无边框窗口支持拖拽调整大小
|
||||
- 代理组新增筛选、排序、延迟测试和快速定位工具
|
||||
- 首页当前节点过滤已隐藏的代理组
|
||||
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 更健壮的服务生命周期管理
|
||||
- 更健壮的 Mihomo API 通信机制
|
||||
- 关闭 autofill 弹出窗口
|
||||
- 改进切换订阅后激活选中节点的逻辑
|
||||
- 实现代理组粘性滚动列表
|
||||
- 完善了配置覆写的相关逻辑
|
||||
- 支持 Provider 节点延迟检测
|
||||
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
|
||||
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
|
||||
- 重构改进订阅配置切换逻辑
|
||||
- 优化大量订阅卡片时的拖拽排序性能
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 👙 界面样式 </strong></summary>
|
||||
|
||||
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
|
||||
- 优化代理组工具栏和筛选框布局
|
||||
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
|
||||
- 优化监听端口冲突检测:代理端口按最终 Runtime 监听范围校验并协调三层持久化,隧道按实际地址与 TCP/UDP 协议检测且保留热重载
|
||||
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
|
||||
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
|
||||
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
|
||||
- 统一系统代理所有权,实现原子化代理切换
|
||||
- 新增旧版 Service 迁移引导与 Sidecar 安全回退
|
||||
- 优化核心启动、停止及更新流程,减少竞态与残留进程
|
||||
- 优化首页当前节点的交互
|
||||
|
||||
</details>
|
||||
|
||||
+20
-6
@@ -25,7 +25,8 @@ command = "pnpm"
|
||||
args = ["typecheck"]
|
||||
|
||||
[tasks.typecheck.windows]
|
||||
command = "pnpm.cmd"
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "typecheck"]
|
||||
|
||||
[tasks.knip-report]
|
||||
description = "Report unused frontend code and dependencies"
|
||||
@@ -33,7 +34,17 @@ command = "pnpm"
|
||||
args = ["knip:report"]
|
||||
|
||||
[tasks.knip-report.windows]
|
||||
command = "pnpm.cmd"
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "knip:report"]
|
||||
|
||||
[tasks.knip-check]
|
||||
description = "Fail on unused frontend code and dependencies"
|
||||
command = "pnpm"
|
||||
args = ["knip:check"]
|
||||
|
||||
[tasks.knip-check.windows]
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "knip:check"]
|
||||
|
||||
[tasks.lint-staged]
|
||||
description = "Run lint-staged for staged files"
|
||||
@@ -41,7 +52,8 @@ command = "pnpm"
|
||||
args = ["exec", "lint-staged"]
|
||||
|
||||
[tasks.lint-staged.windows]
|
||||
command = "pnpm.cmd"
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "exec", "lint-staged"]
|
||||
|
||||
[tasks.i18n-format]
|
||||
description = "Format i18n keys"
|
||||
@@ -49,7 +61,8 @@ command = "pnpm"
|
||||
args = ["i18n:format"]
|
||||
|
||||
[tasks.i18n-format.windows]
|
||||
command = "pnpm.cmd"
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "i18n:format"]
|
||||
|
||||
[tasks.i18n-types]
|
||||
description = "Generate i18n key types"
|
||||
@@ -57,7 +70,8 @@ command = "pnpm"
|
||||
args = ["i18n:types"]
|
||||
|
||||
[tasks.i18n-types.windows]
|
||||
command = "pnpm.cmd"
|
||||
command = "cmd.exe"
|
||||
args = ["/d", "/c", "pnpm", "i18n:types"]
|
||||
|
||||
[tasks.git-add]
|
||||
description = "Add generated files back to git index"
|
||||
@@ -126,6 +140,6 @@ end
|
||||
has_frontend = exec git diff @{u}..HEAD --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
|
||||
if eq ${has_frontend.code} 1
|
||||
cm_run_task typecheck
|
||||
cm_run_task knip-report
|
||||
cm_run_task knip-check
|
||||
end
|
||||
'''
|
||||
|
||||
@@ -107,6 +107,12 @@ pnpm run prebuild
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` preserves the Development Channel's installed service state: an
|
||||
existing service is used, while a previously uninstalled service remains
|
||||
uninstalled and the app starts in Sidecar mode. Use `pnpm dev:service` to
|
||||
explicitly install or update the isolated development service before launch,
|
||||
or `pnpm dev:sidecar` to force the unprivileged Sidecar workflow.
|
||||
|
||||
## Contributions
|
||||
|
||||
Issue and PR welcome!
|
||||
@@ -124,8 +130,4 @@ Clash Verge rev was based on or inspired by these projects and so on:
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 License. See [License here](./LICENSE) for details.
|
||||
|
||||
<p align="right">
|
||||
<sub><a href="https://sponsorship.forztn.com/github/clash-verge-rev/clash-verge-rev">Supported By ForZTN</a></sub>
|
||||
</p>
|
||||
GPL-3.0 License. See [License here](./LICENSE) for details.
|
||||
+4
-1
@@ -10,7 +10,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
@@ -17,6 +17,8 @@ struct DraftInner<T> {
|
||||
data: Mutex<DraftData<T>>,
|
||||
data_modifying: AtomicBool,
|
||||
data_modify_notify: Notify,
|
||||
/// Held by the one [`DraftTransaction`] currently allowed to stage a change.
|
||||
claimed: AtomicBool,
|
||||
}
|
||||
|
||||
struct DataModifyPermit<'a>(&'a AtomicBool, &'a Notify);
|
||||
@@ -42,6 +44,7 @@ impl<T: Clone> Draft<T> {
|
||||
data: Mutex::new((Arc::new(Box::new(data)), None)),
|
||||
data_modifying: AtomicBool::new(false),
|
||||
data_modify_notify: Notify::new(),
|
||||
claimed: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -145,3 +148,137 @@ impl<T: Clone> Draft<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft layer that can be claimed, committed or rolled back without knowing what it holds.
|
||||
///
|
||||
/// Exists so a change spanning several differently-typed layers can be treated as one unit.
|
||||
pub trait DraftLayer: Send + Sync {
|
||||
/// Take exclusive staging rights, or report that someone else holds them.
|
||||
fn try_claim(&self) -> bool;
|
||||
fn release(&self);
|
||||
fn apply_draft(&self);
|
||||
fn discard_draft(&self);
|
||||
/// Names the layer in a [`DraftBusy`] error. Only ever read by humans.
|
||||
fn layer_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl<T: Clone + Send + Sync> DraftLayer for Draft<T> {
|
||||
#[inline]
|
||||
fn try_claim(&self) -> bool {
|
||||
self.inner
|
||||
.claimed
|
||||
.compare_exchange(false, true, Acquire, Relaxed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn release(&self) {
|
||||
self.inner.claimed.store(false, Release);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_draft(&self) {
|
||||
self.apply();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn discard_draft(&self) {
|
||||
self.discard();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn layer_name(&self) -> &'static str {
|
||||
std::any::type_name::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Another transaction is already staging a change to one of the layers asked for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DraftBusy {
|
||||
/// The layer that was already claimed.
|
||||
pub layer: &'static str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DraftBusy {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"configuration layer {} is being changed elsewhere",
|
||||
self.layer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DraftBusy {}
|
||||
|
||||
/// Commits a set of draft layers together, or rolls them all back.
|
||||
///
|
||||
/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a
|
||||
/// change half-applied across layers. Committing is the explicit act, because forgetting to
|
||||
/// commit costs a retry while forgetting to roll back leaves the app describing a state it is
|
||||
/// not in.
|
||||
///
|
||||
/// Beginning one takes exclusive staging rights over every layer, and overlapping with another
|
||||
/// transaction is refused rather than allowed to interleave. That is not caution, it is the
|
||||
/// only honest option: a layer holds exactly one draft, so a second writer's `edit_draft`
|
||||
/// overwrites the first writer's staged value *in place*. There is no version of "roll back
|
||||
/// only my edit" that could be implemented — rolling back would discard a change the other
|
||||
/// writer is about to commit, and it did: the other writer's `apply` then found no draft, did
|
||||
/// nothing at all, and still reported success while its side effect had already happened.
|
||||
#[must_use = "a transaction that is dropped without committing rolls back"]
|
||||
pub struct DraftTransaction<'a> {
|
||||
layers: Vec<&'a dyn DraftLayer>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl<'a> DraftTransaction<'a> {
|
||||
/// Claim `layers` and begin a transaction over them; they are committed in the order given.
|
||||
///
|
||||
/// Fails if any layer is already claimed, having released the ones taken so far, so a
|
||||
/// refused attempt leaves nothing held.
|
||||
pub fn begin(layers: Vec<&'a dyn DraftLayer>) -> Result<Self, DraftBusy> {
|
||||
for (index, layer) in layers.iter().enumerate() {
|
||||
if layer.try_claim() {
|
||||
continue;
|
||||
}
|
||||
for claimed in &layers[..index] {
|
||||
claimed.release();
|
||||
}
|
||||
return Err(DraftBusy {
|
||||
layer: layer.layer_name(),
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
layers,
|
||||
committed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Commit every layer. Consumes the transaction so it cannot be committed twice.
|
||||
pub fn commit(mut self) {
|
||||
for layer in &self.layers {
|
||||
layer.apply_draft();
|
||||
}
|
||||
self.committed = true;
|
||||
}
|
||||
|
||||
/// Roll every layer back now, rather than whenever this value happens to drop.
|
||||
///
|
||||
/// Needed where recovery does more than forget the drafts — restoring files, or
|
||||
/// regenerating them from the committed configuration. Those read the layers, so the
|
||||
/// rollback has to have already happened, not be pending on a later drop.
|
||||
pub fn rollback(self) {
|
||||
drop(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DraftTransaction<'_> {
|
||||
fn drop(&mut self) {
|
||||
for layer in &self.layers {
|
||||
if !self.committed {
|
||||
layer.discard_draft();
|
||||
}
|
||||
layer.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,3 +303,145 @@ mod tests {
|
||||
assert_eq!(latest.enable_tun_mode, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
mod transactions {
|
||||
use clash_verge_draft::{Draft, DraftTransaction};
|
||||
|
||||
fn begin<'a>(layers: Vec<&'a dyn clash_verge_draft::DraftLayer>) -> DraftTransaction<'a> {
|
||||
DraftTransaction::begin(layers).expect("no other transaction should hold these layers")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_applies_every_layer() {
|
||||
let first = Draft::new(1_u8);
|
||||
let second = Draft::new("a".to_owned());
|
||||
|
||||
let transaction = begin(vec![&first, &second]);
|
||||
first.edit_draft(|value| *value = 2);
|
||||
second.edit_draft(|value| *value = "b".to_owned());
|
||||
transaction.commit();
|
||||
|
||||
assert_eq!(**first.data_arc(), 2);
|
||||
assert_eq!(**second.data_arc(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_without_committing_rolls_every_layer_back() {
|
||||
let first = Draft::new(1_u8);
|
||||
let second = Draft::new("a".to_owned());
|
||||
|
||||
{
|
||||
let _transaction = begin(vec![&first, &second]);
|
||||
first.edit_draft(|value| *value = 2);
|
||||
second.edit_draft(|value| *value = "b".to_owned());
|
||||
}
|
||||
|
||||
assert_eq!(**first.data_arc(), 1);
|
||||
assert_eq!(**second.data_arc(), "a");
|
||||
assert_eq!(
|
||||
**first.latest_arc(),
|
||||
1,
|
||||
"the draft itself is gone, not just uncommitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_early_return_rolls_back_without_the_caller_saying_so() {
|
||||
fn attempt(draft: &Draft<u8>, succeed: bool) -> Result<(), &'static str> {
|
||||
let transaction = begin(vec![draft]);
|
||||
draft.edit_draft(|value| *value = 9);
|
||||
if !succeed {
|
||||
return Err("failed after editing");
|
||||
}
|
||||
transaction.commit();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let draft = Draft::new(1_u8);
|
||||
assert!(attempt(&draft, false).is_err());
|
||||
assert_eq!(**draft.data_arc(), 1, "a failed attempt leaves nothing behind");
|
||||
|
||||
assert!(attempt(&draft, true).is_ok());
|
||||
assert_eq!(**draft.data_arc(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partly_edited_transaction_still_rolls_back_whole() {
|
||||
// The layer nobody edited must not be left holding a stale draft either.
|
||||
let edited = Draft::new(1_u8);
|
||||
let untouched = Draft::new(5_u8);
|
||||
|
||||
drop(begin(vec![&edited, &untouched]));
|
||||
edited.edit_draft(|value| *value = 2);
|
||||
|
||||
{
|
||||
let _transaction = begin(vec![&edited, &untouched]);
|
||||
}
|
||||
|
||||
assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too");
|
||||
assert_eq!(**untouched.latest_arc(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
mod transaction_exclusivity {
|
||||
use clash_verge_draft::{Draft, DraftTransaction};
|
||||
|
||||
#[test]
|
||||
fn a_second_transaction_over_a_claimed_layer_is_refused() {
|
||||
let draft = Draft::new(1_u8);
|
||||
let _held = DraftTransaction::begin(vec![&draft]).expect("the first claim should succeed");
|
||||
|
||||
let refused = DraftTransaction::begin(vec![&draft]);
|
||||
|
||||
assert!(refused.is_err(), "a layer may only be staged by one transaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_transaction_leaves_the_layers_it_did_claim_free() {
|
||||
// Claiming is all-or-nothing: a caller that failed on the third layer must not keep
|
||||
// the first two, or the next attempt would be refused for a transaction that no
|
||||
// longer exists.
|
||||
let first = Draft::new(1_u8);
|
||||
let second = Draft::new(2_u8);
|
||||
let _contended = DraftTransaction::begin(vec![&second]).expect("the first claim should succeed");
|
||||
|
||||
assert!(DraftTransaction::begin(vec![&first, &second]).is_err());
|
||||
|
||||
drop(DraftTransaction::begin(vec![&first]).expect("the layer that was rolled off must be free"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_is_free_again_once_its_transaction_ends() {
|
||||
let draft = Draft::new(1_u8);
|
||||
|
||||
drop(DraftTransaction::begin(vec![&draft]).expect("first"));
|
||||
let committed = DraftTransaction::begin(vec![&draft]).expect("free after a rollback");
|
||||
committed.commit();
|
||||
|
||||
drop(DraftTransaction::begin(vec![&draft]).expect("free after a commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlapping_writer_can_no_longer_discard_a_staged_edit() {
|
||||
// The regression this pins. A holds a transaction across a slow side effect; B starts,
|
||||
// stages its own edit over the one draft slot, fails, and rolls back. Before layers
|
||||
// were claimed, B's rollback cleared the single draft, A's commit then found nothing to
|
||||
// apply and did nothing at all — while A's caller was told it had succeeded and A's own
|
||||
// side effect had already happened.
|
||||
let verge = Draft::new(String::from("committed"));
|
||||
|
||||
let transaction_a = DraftTransaction::begin(vec![&verge]).expect("A claims the layer");
|
||||
verge.edit_draft(|value| *value = String::from("A-wants-this"));
|
||||
|
||||
let transaction_b = DraftTransaction::begin(vec![&verge]);
|
||||
assert!(transaction_b.is_err(), "B must be refused rather than share the slot");
|
||||
|
||||
transaction_a.commit();
|
||||
|
||||
assert_eq!(
|
||||
**verge.data_arc(),
|
||||
"A-wants-this",
|
||||
"A's edit must survive an overlapping writer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.1.0"
|
||||
rust-i18n = "4.2.1"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
||||
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
||||
translocated: يشغّل macOS هذا التطبيق من مسار App Translocation مؤقت. انقله يدويًا في Finder إلى /Applications أو ~/Applications، ثم افتحه مجددًا.
|
||||
rejected: |-
|
||||
يجب تشغيل هذا التطبيق من مجلد Applications.
|
||||
|
||||
{reason}
|
||||
notInBundle: الملف التنفيذي ليس داخل حزمة تطبيق.
|
||||
canonicalizeBundleError: 'تعذر تحديد المسار الفعلي لحزمة التطبيق: {error}'
|
||||
bundleNameError: تعذر تحديد اسم حزمة التطبيق. سيتم إنهاء التطبيق.
|
||||
replacePrompt: يوجد تطبيق بالفعل في {destination}. هل تريد استبداله وإعادة تشغيله من Applications؟
|
||||
movePrompt: لتشغيل خدمة الخلفية بأمان، يجب نقل التطبيق إلى {destination}. هل تريد نقله وإعادة تشغيله الآن؟
|
||||
createApplicationsDirError: 'تعذر إنشاء مجلد Applications: {error}'
|
||||
moveFailed: فشل نقل التطبيق. انقله يدويًا في Finder ثم أعد المحاولة.
|
||||
replaceFailed: 'تعذر استبدال التطبيق الموجود بأمان: {error}'
|
||||
relaunchFailed: تم نسخ التطبيق، ولكن تعذرت إعادة تشغيله تلقائيًا. افتحه يدويًا من Applications.
|
||||
cancel: إلغاء
|
||||
move: نقل
|
||||
ok: موافق
|
||||
tray:
|
||||
dashboard: لوحة التحكم
|
||||
ruleMode: وضع القواعد
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
|
||||
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
|
||||
translocated: macOS führt diese Anwendung aus einem temporären App-Translocation-Pfad aus. Verschieben Sie sie im Finder manuell nach /Applications oder ~/Applications und öffnen Sie sie anschließend erneut.
|
||||
rejected: |-
|
||||
Diese Anwendung muss aus einem Applications-Ordner gestartet werden.
|
||||
|
||||
{reason}
|
||||
notInBundle: Die ausführbare Datei befindet sich nicht in einem Anwendungspaket.
|
||||
canonicalizeBundleError: 'Der Pfad des Anwendungspakets konnte nicht aufgelöst werden: {error}'
|
||||
bundleNameError: Der Name des Anwendungspakets konnte nicht ermittelt werden. Die Anwendung wird beendet.
|
||||
replacePrompt: Unter {destination} ist bereits eine Anwendung vorhanden. Soll sie ersetzt und aus Applications neu gestartet werden?
|
||||
movePrompt: Damit der Hintergrunddienst sicher gestartet werden kann, muss die Anwendung nach {destination} verschoben werden. Jetzt verschieben und neu starten?
|
||||
createApplicationsDirError: 'Der Applications-Ordner konnte nicht erstellt werden: {error}'
|
||||
moveFailed: Die Anwendung konnte nicht verschoben werden. Verschieben Sie sie im Finder manuell und versuchen Sie es erneut.
|
||||
replaceFailed: 'Die vorhandene Anwendung konnte nicht sicher ersetzt werden: {error}'
|
||||
relaunchFailed: Die Anwendung wurde kopiert, konnte aber nicht automatisch neu gestartet werden. Öffnen Sie sie manuell aus Applications.
|
||||
cancel: Abbrechen
|
||||
move: Verschieben
|
||||
ok: OK
|
||||
tray:
|
||||
dashboard: Übersicht
|
||||
ruleMode: Regelmodus
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Unable to determine the application location: {error}'
|
||||
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
||||
translocated: macOS is running this application from a temporary App Translocation path. In Finder, move it manually to /Applications or ~/Applications, then reopen it.
|
||||
rejected: |-
|
||||
This application must be launched from an Applications directory.
|
||||
|
||||
{reason}
|
||||
notInBundle: The executable is not inside an application bundle.
|
||||
canonicalizeBundleError: 'Unable to resolve the application bundle path: {error}'
|
||||
bundleNameError: Unable to determine the application bundle name. The application will exit.
|
||||
replacePrompt: An application already exists at {destination}. Replace it and relaunch from Applications?
|
||||
movePrompt: To start the background service safely, the application must be moved to {destination}. Move and relaunch it now?
|
||||
createApplicationsDirError: 'Unable to create the Applications directory: {error}'
|
||||
moveFailed: Failed to move the application. Move it manually in Finder and try again.
|
||||
replaceFailed: 'Unable to replace the existing application safely: {error}'
|
||||
relaunchFailed: The application was copied, but it could not be relaunched automatically. Open it manually from Applications.
|
||||
cancel: Cancel
|
||||
move: Move
|
||||
ok: OK
|
||||
tray:
|
||||
dashboard: Dashboard
|
||||
ruleMode: Rule Mode
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
|
||||
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
|
||||
translocated: macOS está ejecutando esta aplicación desde una ruta temporal de App Translocation. Muévela manualmente en Finder a /Applications o ~/Applications y vuelve a abrirla.
|
||||
rejected: |-
|
||||
Esta aplicación debe iniciarse desde una carpeta Applications.
|
||||
|
||||
{reason}
|
||||
notInBundle: El archivo ejecutable no está dentro de un paquete de aplicación.
|
||||
canonicalizeBundleError: 'No se pudo resolver la ruta del paquete de la aplicación: {error}'
|
||||
bundleNameError: No se pudo determinar el nombre del paquete de la aplicación. La aplicación se cerrará.
|
||||
replacePrompt: Ya existe una aplicación en {destination}. ¿Quieres reemplazarla y reiniciarla desde Applications?
|
||||
movePrompt: Para iniciar el servicio en segundo plano de forma segura, la aplicación debe moverse a {destination}. ¿Quieres moverla y reiniciarla ahora?
|
||||
createApplicationsDirError: 'No se pudo crear la carpeta Applications: {error}'
|
||||
moveFailed: No se pudo mover la aplicación. Muévela manualmente en Finder e inténtalo de nuevo.
|
||||
replaceFailed: 'No se pudo reemplazar de forma segura la aplicación existente: {error}'
|
||||
relaunchFailed: La aplicación se copió, pero no se pudo reiniciar automáticamente. Ábrela manualmente desde Applications.
|
||||
cancel: Cancelar
|
||||
move: Mover
|
||||
ok: Aceptar
|
||||
tray:
|
||||
dashboard: Panel
|
||||
ruleMode: Modo de reglas
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
||||
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
||||
translocated: macOS این برنامه را از یک مسیر موقت App Translocation اجرا میکند. آن را در Finder بهصورت دستی به /Applications یا ~/Applications منتقل کنید و دوباره باز کنید.
|
||||
rejected: |-
|
||||
این برنامه باید از پوشهٔ Applications اجرا شود.
|
||||
|
||||
{reason}
|
||||
notInBundle: فایل اجرایی داخل بستهٔ برنامه نیست.
|
||||
canonicalizeBundleError: 'مسیر بستهٔ برنامه قابل تشخیص نیست: {error}'
|
||||
bundleNameError: نام بستهٔ برنامه مشخص نشد. برنامه بسته خواهد شد.
|
||||
replacePrompt: در {destination} از قبل برنامهای وجود دارد. جایگزین و از Applications دوباره اجرا شود؟
|
||||
movePrompt: برای اجرای امن سرویس پسزمینه، برنامه باید به {destination} منتقل شود. اکنون منتقل و دوباره اجرا شود؟
|
||||
createApplicationsDirError: 'پوشهٔ Applications ایجاد نشد: {error}'
|
||||
moveFailed: انتقال برنامه ناموفق بود. آن را در Finder بهصورت دستی منتقل کنید و دوباره تلاش کنید.
|
||||
replaceFailed: 'برنامهٔ موجود بهصورت امن جایگزین نشد: {error}'
|
||||
relaunchFailed: برنامه کپی شد، اما اجرای دوبارهٔ خودکار ناموفق بود. آن را بهصورت دستی از Applications باز کنید.
|
||||
cancel: لغو
|
||||
move: انتقال
|
||||
ok: تأیید
|
||||
tray:
|
||||
dashboard: داشبورد
|
||||
ruleMode: حالت قوانین
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
||||
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
||||
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
||||
translocated: macOS menjalankan aplikasi ini dari jalur App Translocation sementara. Pindahkan secara manual di Finder ke /Applications atau ~/Applications, lalu buka kembali.
|
||||
rejected: |-
|
||||
Aplikasi ini harus dijalankan dari direktori Applications.
|
||||
|
||||
{reason}
|
||||
notInBundle: Berkas yang dapat dijalankan tidak berada di dalam bundel aplikasi.
|
||||
canonicalizeBundleError: 'Jalur bundel aplikasi tidak dapat diuraikan: {error}'
|
||||
bundleNameError: Nama bundel aplikasi tidak dapat ditentukan. Aplikasi akan ditutup.
|
||||
replacePrompt: Sudah ada aplikasi di {destination}. Ganti dan jalankan ulang dari Applications?
|
||||
movePrompt: Agar layanan latar belakang dapat dimulai dengan aman, aplikasi harus dipindahkan ke {destination}. Pindahkan dan jalankan ulang sekarang?
|
||||
createApplicationsDirError: 'Direktori Applications tidak dapat dibuat: {error}'
|
||||
moveFailed: Aplikasi gagal dipindahkan. Pindahkan secara manual di Finder lalu coba lagi.
|
||||
replaceFailed: 'Aplikasi yang ada tidak dapat diganti dengan aman: {error}'
|
||||
relaunchFailed: Aplikasi telah disalin, tetapi tidak dapat dijalankan ulang secara otomatis. Buka secara manual dari Applications.
|
||||
cancel: Batal
|
||||
move: Pindahkan
|
||||
ok: OK
|
||||
tray:
|
||||
dashboard: Dasbor
|
||||
ruleMode: Mode Aturan
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
||||
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
||||
translocated: macOS が一時的な App Translocation パスからこのアプリケーションを実行しています。Finder で /Applications または ~/Applications に手動で移動してから、もう一度開いてください。
|
||||
rejected: |-
|
||||
このアプリケーションは Applications ディレクトリから起動する必要があります。
|
||||
|
||||
{reason}
|
||||
notInBundle: 実行ファイルがアプリケーションバンドル内にありません。
|
||||
canonicalizeBundleError: アプリケーションバンドルのパスを解決できませんでした:{error}
|
||||
bundleNameError: アプリケーションバンドル名を確認できないため、アプリケーションを終了します。
|
||||
replacePrompt: '{destination} には既にアプリケーションがあります。置き換えて Applications から再起動しますか?'
|
||||
movePrompt: バックグラウンドサービスを安全に起動するには、アプリケーションを {destination} に移動する必要があります。今すぐ移動して再起動しますか?
|
||||
createApplicationsDirError: Applications ディレクトリを作成できませんでした:{error}
|
||||
moveFailed: アプリケーションを移動できませんでした。Finder で手動で移動してから、もう一度お試しください。
|
||||
replaceFailed: 既存のアプリケーションを安全に置き換えられませんでした:{error}
|
||||
relaunchFailed: アプリケーションはコピーされましたが、自動的に再起動できませんでした。Applications から手動で開いてください。
|
||||
cancel: キャンセル
|
||||
move: 移動
|
||||
ok: OK
|
||||
tray:
|
||||
dashboard: ダッシュボード
|
||||
ruleMode: ルールモード
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
||||
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
||||
translocated: macOS가 임시 App Translocation 경로에서 이 애플리케이션을 실행하고 있습니다. Finder에서 /Applications 또는 ~/Applications로 직접 이동한 후 다시 여십시오.
|
||||
rejected: |-
|
||||
이 애플리케이션은 Applications 디렉터리에서 실행해야 합니다.
|
||||
|
||||
{reason}
|
||||
notInBundle: 실행 파일이 애플리케이션 번들 안에 없습니다.
|
||||
canonicalizeBundleError: '애플리케이션 번들 경로를 확인할 수 없습니다: {error}'
|
||||
bundleNameError: 애플리케이션 번들 이름을 확인할 수 없어 애플리케이션을 종료합니다.
|
||||
replacePrompt: '{destination}에 애플리케이션이 이미 있습니다. 교체하고 Applications에서 다시 실행하시겠습니까?'
|
||||
movePrompt: 백그라운드 서비스를 안전하게 시작하려면 애플리케이션을 {destination}(으)로 이동해야 합니다. 지금 이동하고 다시 실행하시겠습니까?
|
||||
createApplicationsDirError: 'Applications 디렉터리를 만들 수 없습니다: {error}'
|
||||
moveFailed: 애플리케이션을 이동하지 못했습니다. Finder에서 직접 이동한 후 다시 시도하십시오.
|
||||
replaceFailed: '기존 애플리케이션을 안전하게 교체할 수 없습니다: {error}'
|
||||
relaunchFailed: 애플리케이션을 복사했지만 자동으로 다시 실행하지 못했습니다. Applications에서 직접 여십시오.
|
||||
cancel: 취소
|
||||
move: 이동
|
||||
ok: 확인
|
||||
tray:
|
||||
dashboard: 대시보드
|
||||
ruleMode: 규칙 모드
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
||||
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
||||
translocated: macOS запускает это приложение из временного пути App Translocation. Переместите его вручную в Finder в /Applications или ~/Applications, затем откройте снова.
|
||||
rejected: |-
|
||||
Это приложение необходимо запускать из каталога Applications.
|
||||
|
||||
{reason}
|
||||
notInBundle: Исполняемый файл не находится внутри пакета приложения.
|
||||
canonicalizeBundleError: 'Не удалось определить путь к пакету приложения: {error}'
|
||||
bundleNameError: Не удалось определить имя пакета приложения. Приложение будет закрыто.
|
||||
replacePrompt: В {destination} уже есть приложение. Заменить его и перезапустить из Applications?
|
||||
movePrompt: Для безопасного запуска фоновой службы приложение необходимо переместить в {destination}. Переместить и перезапустить сейчас?
|
||||
createApplicationsDirError: 'Не удалось создать каталог Applications: {error}'
|
||||
moveFailed: Не удалось переместить приложение. Переместите его вручную в Finder и повторите попытку.
|
||||
replaceFailed: 'Не удалось безопасно заменить существующее приложение: {error}'
|
||||
relaunchFailed: Приложение скопировано, но его не удалось перезапустить автоматически. Откройте его вручную из Applications.
|
||||
cancel: Отмена
|
||||
move: Переместить
|
||||
ok: ОК
|
||||
tray:
|
||||
dashboard: Панель
|
||||
ruleMode: Режим правил
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
||||
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
||||
translocated: macOS bu uygulamayı geçici bir App Translocation yolundan çalıştırıyor. Finder'da uygulamayı elle /Applications veya ~/Applications dizinine taşıyın ve yeniden açın.
|
||||
rejected: |-
|
||||
Bu uygulama bir Applications dizininden başlatılmalıdır.
|
||||
|
||||
{reason}
|
||||
notInBundle: Yürütülebilir dosya bir uygulama paketinin içinde değil.
|
||||
canonicalizeBundleError: 'Uygulama paketinin yolu çözümlenemedi: {error}'
|
||||
bundleNameError: Uygulama paketinin adı belirlenemedi. Uygulama kapatılacak.
|
||||
replacePrompt: '{destination} konumunda zaten bir uygulama var. Değiştirilsin ve Applications üzerinden yeniden başlatılsın mı?'
|
||||
movePrompt: Arka plan hizmetini güvenli şekilde başlatmak için uygulamanın {destination} konumuna taşınması gerekir. Şimdi taşınıp yeniden başlatılsın mı?
|
||||
createApplicationsDirError: 'Applications dizini oluşturulamadı: {error}'
|
||||
moveFailed: Uygulama taşınamadı. Finder'da elle taşıyıp tekrar deneyin.
|
||||
replaceFailed: 'Mevcut uygulama güvenli şekilde değiştirilemedi: {error}'
|
||||
relaunchFailed: Uygulama kopyalandı ancak otomatik olarak yeniden başlatılamadı. Applications üzerinden elle açın.
|
||||
cancel: İptal
|
||||
move: Taşı
|
||||
ok: Tamam
|
||||
tray:
|
||||
dashboard: Gösterge Paneli
|
||||
ruleMode: Kural Modu
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
||||
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
||||
translocated: macOS бу кушымтаны вакытлы App Translocation юлыннан эшләтә. Finder аша аны /Applications яки ~/Applications каталогына кулдан күчерегез һәм яңадан ачыгыз.
|
||||
rejected: |-
|
||||
Бу кушымта Applications каталогыннан эшләтелергә тиеш.
|
||||
|
||||
{reason}
|
||||
notInBundle: Башкарылучы файл кушымта пакетында түгел.
|
||||
canonicalizeBundleError: 'Кушымта пакетының юлын билгеләп булмады: {error}'
|
||||
bundleNameError: Кушымта пакетының исемен билгеләп булмады. Кушымта ябылачак.
|
||||
replacePrompt: '{destination} урынында кушымта бар инде. Аны алыштырып, Applications аша яңадан эшләтергәме?'
|
||||
movePrompt: Фон хезмәтен имин эшләтү өчен кушымтаны {destination} урынына күчерергә кирәк. Хәзер күчереп, яңадан эшләтергәме?
|
||||
createApplicationsDirError: 'Applications каталогын төзеп булмады: {error}'
|
||||
moveFailed: Кушымтаны күчереп булмады. Аны Finder аша кулдан күчереп, кабатлап карагыз.
|
||||
replaceFailed: 'Булган кушымтаны имин алыштырып булмады: {error}'
|
||||
relaunchFailed: Кушымта күчерелде, ләкин автоматик рәвештә яңадан эшләтеп булмады. Аны Applications аша кулдан ачыгыз.
|
||||
cancel: Баш тарту
|
||||
move: Күчерү
|
||||
ok: Ярый
|
||||
tray:
|
||||
dashboard: Идарә панеле
|
||||
ruleMode: Кагыйдә режимы
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||
translocatedCorePath: 检测到 Clash Verge 从临时路径(App Translocation)运行,无法定位内核。请将 Clash Verge 移动到「应用程序」文件夹后重新打开。
|
||||
launchGuard:
|
||||
currentExeError: 无法确定应用位置:{error}
|
||||
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
||||
translocated: macOS 正在从 App Translocation 临时路径运行此应用。请在 Finder 中将应用手动拖到 /Applications 或 ~/Applications 后重新打开。
|
||||
rejected: |-
|
||||
此应用必须从 Applications 目录启动。
|
||||
|
||||
{reason}
|
||||
notInBundle: 可执行文件不在应用包内。
|
||||
canonicalizeBundleError: 无法解析应用包路径:{error}
|
||||
bundleNameError: 无法确定应用包名称,应用将退出。
|
||||
replacePrompt: 目标位置已存在应用:{destination}。是否替换并从 Applications 重新启动?
|
||||
movePrompt: 为了安全启动后台服务,需要将应用移动到 {destination}。是否现在移动并重新启动?
|
||||
createApplicationsDirError: 无法创建 Applications 目录:{error}
|
||||
moveFailed: 移动应用失败。请在 Finder 中手动移动后重试。
|
||||
replaceFailed: 无法安全替换现有应用:{error}
|
||||
relaunchFailed: 应用已复制,但自动重新启动失败。请从 Applications 手动打开。
|
||||
cancel: 取消
|
||||
move: 移动
|
||||
ok: 好
|
||||
tray:
|
||||
dashboard: 仪表板
|
||||
ruleMode: 规则模式
|
||||
|
||||
@@ -34,7 +34,26 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
|
||||
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
|
||||
launchGuard:
|
||||
currentExeError: 無法判斷應用程式位置:{error}
|
||||
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
||||
translocated: macOS 正從 App Translocation 暫存路徑執行此應用程式。請在 Finder 中將應用程式手動移至 /Applications 或 ~/Applications,然後重新開啟。
|
||||
rejected: |-
|
||||
此應用程式必須從 Applications 目錄啟動。
|
||||
|
||||
{reason}
|
||||
notInBundle: 可執行檔不在應用程式套件內。
|
||||
canonicalizeBundleError: 無法解析應用程式套件路徑:{error}
|
||||
bundleNameError: 無法判斷應用程式套件名稱,應用程式將結束。
|
||||
replacePrompt: 目標位置已有應用程式:{destination}。是否取代並從 Applications 重新啟動?
|
||||
movePrompt: 為了安全啟動背景服務,需要將應用程式移至 {destination}。是否立即移動並重新啟動?
|
||||
createApplicationsDirError: 無法建立 Applications 目錄:{error}
|
||||
moveFailed: 移動應用程式失敗。請在 Finder 中手動移動後再試一次。
|
||||
replaceFailed: 無法安全取代現有應用程式:{error}
|
||||
relaunchFailed: 應用程式已複製,但無法自動重新啟動。請從 Applications 手動開啟。
|
||||
cancel: 取消
|
||||
move: 移動
|
||||
ok: 好
|
||||
tray:
|
||||
dashboard: 儀表板
|
||||
ruleMode: 規則模式
|
||||
|
||||
@@ -39,7 +39,7 @@ fn resolve_supported_language(language: &str) -> Option<Cow<'static, str>> {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn current_language(language: Option<&str>) -> Cow<'static, str> {
|
||||
pub fn current_language(language: Option<&str>) -> Cow<'static, str> {
|
||||
language
|
||||
.filter(|lang| !lang.is_empty())
|
||||
.and_then(resolve_supported_language)
|
||||
|
||||
@@ -97,23 +97,35 @@ pub fn write_sidecar_log(
|
||||
let _ = writer.write(now, &record);
|
||||
}
|
||||
|
||||
pub struct NoModuleFilter<'a>(pub Vec<&'a str>);
|
||||
pub struct ModuleFilter<'a> {
|
||||
block: Vec<&'a str>,
|
||||
exclude: Option<Vec<&'a str>>,
|
||||
}
|
||||
|
||||
impl<'a> ModuleFilter<'a> {
|
||||
pub fn new(block: Vec<&'a str>, exclude: Option<Vec<&'a str>>) -> Self {
|
||||
Self { block, exclude }
|
||||
}
|
||||
|
||||
impl<'a> NoModuleFilter<'a> {
|
||||
#[inline]
|
||||
pub fn filter(&self, record: &Record) -> bool {
|
||||
if let Some(module) = record.module_path() {
|
||||
for blocked in self.0.iter() {
|
||||
if module.len() >= blocked.len() && module.as_bytes()[..blocked.len()] == blocked.as_bytes()[..] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let Some(module) = record.module_path() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
// 优先检查排除(白名单)
|
||||
if let Some(excludes) = &self.exclude
|
||||
&& excludes.iter().any(|e| module.starts_with(e))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
true
|
||||
|
||||
// 再检查阻止(黑名单)
|
||||
!self.block.iter().any(|b| module.starts_with(b))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> LogLineFilter for NoModuleFilter<'a> {
|
||||
impl<'a> LogLineFilter for ModuleFilter<'a> {
|
||||
#[inline]
|
||||
fn write(
|
||||
&self,
|
||||
|
||||
@@ -7,7 +7,7 @@ rust-version = "1.91"
|
||||
[dependencies]
|
||||
clash-verge-logging = { workspace = true }
|
||||
log = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio = { workspace = true, features = ["signal"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
@@ -8,11 +11,42 @@ mod unix;
|
||||
mod windows;
|
||||
|
||||
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
|
||||
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ShutdownOutcome {
|
||||
Committed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
pub(crate) struct ShutdownLatch {
|
||||
is_cleaning_up: AtomicBool,
|
||||
}
|
||||
|
||||
impl ShutdownLatch {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
is_cleaning_up: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_begin(&self) -> bool {
|
||||
self.is_cleaning_up
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn finish(&self, outcome: ShutdownOutcome) {
|
||||
if matches!(outcome, ShutdownOutcome::Canceled) {
|
||||
self.is_cleaning_up.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
{
|
||||
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => Some(rt),
|
||||
@@ -33,3 +67,30 @@ where
|
||||
#[cfg(windows)]
|
||||
windows::register(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ShutdownLatch, ShutdownOutcome};
|
||||
|
||||
#[test]
|
||||
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
assert!(!latch.try_begin());
|
||||
|
||||
latch.finish(ShutdownOutcome::Canceled);
|
||||
|
||||
assert!(latch.try_begin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
latch.finish(ShutdownOutcome::Committed);
|
||||
|
||||
assert!(!latch.try_begin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
use crate::RUNTIME;
|
||||
|
||||
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
@@ -53,7 +49,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
if IS_CLEANING_UP.load(Ordering::SeqCst) {
|
||||
if !SHUTDOWN_LATCH.try_begin() {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
@@ -62,11 +58,9 @@ where
|
||||
);
|
||||
continue;
|
||||
}
|
||||
IS_CLEANING_UP.store(true, Ordering::SeqCst);
|
||||
|
||||
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
|
||||
|
||||
f().await;
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::windows;
|
||||
|
||||
use crate::RUNTIME;
|
||||
|
||||
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future + Send + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
@@ -63,7 +59,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
if IS_CLEANING_UP.load(Ordering::SeqCst) {
|
||||
if !SHUTDOWN_LATCH.try_begin() {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
@@ -72,11 +68,9 @@ where
|
||||
);
|
||||
continue;
|
||||
}
|
||||
IS_CLEANING_UP.store(true, Ordering::SeqCst);
|
||||
|
||||
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
|
||||
|
||||
f().await;
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -11,10 +11,10 @@ parking_lot = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# sysinfo 0.38.2 conflicts with dark-light
|
||||
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
||||
sysinfo = { version = "0.39.5", features = ["network", "system"] }
|
||||
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.186"
|
||||
libc = "0.2.189"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
deelevate = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,88 @@
|
||||
## v2.5.2
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
- 修复 macOS 托盘速率显示样式异常
|
||||
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
|
||||
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
|
||||
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
|
||||
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
|
||||
- 修复前端连接页面导致的内存泄漏
|
||||
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
|
||||
- 修复首页代理卡片可能错误显示通信异常的问题
|
||||
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
|
||||
- 修复 DNS 覆写的高级模式无法正常编辑
|
||||
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
|
||||
- 修复 Linux 应用内更新问题
|
||||
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
|
||||
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
|
||||
- 修复全局热键开关设置可能被意外覆盖的问题
|
||||
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
|
||||
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
|
||||
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
|
||||
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
|
||||
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
|
||||
- 修复 macOS 托盘图标可能闪烁的问题
|
||||
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
|
||||
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
|
||||
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
|
||||
- 修复首页上传和下载总流量统计不准确的问题
|
||||
- 修复首页当前代理状态偶尔不同步的问题
|
||||
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
|
||||
- 修复切换代理模式失败时未正确显示错误的问题
|
||||
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
|
||||
- 修复全局快捷键隐藏主窗口行为异常的问题
|
||||
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
|
||||
- 修复日志刷新时页面可能被强制滚动到底部的问题
|
||||
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
|
||||
- 修复代理组筛选后定位及滚动位置恢复异常的问题
|
||||
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
|
||||
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
|
||||
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
|
||||
- 修复内核启动慢导致首页上下跳动的问题
|
||||
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
|
||||
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
|
||||
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- **Mihomo(Meta) 内核升级至 v1.19.29**
|
||||
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
|
||||
- 全局扩展脚本增加恢复默认按钮
|
||||
- DNS 添加 fake-ip-range6 可配置项
|
||||
- Linux 无边框窗口支持拖拽调整大小
|
||||
- 代理组新增筛选、排序、延迟测试和快速定位工具
|
||||
- 首页当前节点过滤已隐藏的代理组
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 更健壮的服务生命周期管理
|
||||
- 更健壮的 Mihomo API 通信机制
|
||||
- 关闭 autofill 弹出窗口
|
||||
- 改进切换订阅后激活选中节点的逻辑
|
||||
- 实现代理组粘性滚动列表
|
||||
- 完善了配置覆写的相关逻辑
|
||||
- 支持 Provider 节点延迟检测
|
||||
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
|
||||
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
|
||||
- 重构改进订阅配置切换逻辑
|
||||
- 优化大量订阅卡片时的拖拽排序性能
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 👙 界面样式 </strong></summary>
|
||||
|
||||
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
|
||||
- 优化代理组工具栏和筛选框布局
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.1
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
@@ -43,7 +43,9 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+25
-17
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
"dev": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
|
||||
"dev:diff": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
|
||||
"dev:trace": "cross-env RUST_BACKTRACE=full RUSTFLAGS=\"--cfg tokio_unstable\" tauri dev -f verge-dev tokio-trace",
|
||||
"dev": "node scripts/dev.mjs dev",
|
||||
"dev:service": "node scripts/dev.mjs service",
|
||||
"dev:sidecar": "node scripts/dev.mjs sidecar",
|
||||
"dev:diff": "node scripts/dev.mjs dev",
|
||||
"dev:trace": "node scripts/dev.mjs trace",
|
||||
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||
@@ -24,6 +26,7 @@
|
||||
"release:autobuild": "pnpm release-version autobuild",
|
||||
"release:deploytest": "pnpm release-version deploytest",
|
||||
"publish-version": "node scripts/publish-version.mjs",
|
||||
"eslint": "pnpm lint",
|
||||
"lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src",
|
||||
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
|
||||
"format": "biome format --write .",
|
||||
@@ -32,6 +35,10 @@
|
||||
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:dev-control": "node --test scripts/dev-control.test.mjs scripts/service-release.test.mjs",
|
||||
"knip:check": "knip",
|
||||
"knip:report": "knip --no-exit-code"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -50,7 +57,6 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.5.9",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"ahooks": "^3.9.7",
|
||||
@@ -59,23 +65,23 @@
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^5.0.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.25",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"nanoid": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"swr": "^2.4.2",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#refactor-use-reqwest-ipc",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
@@ -85,7 +91,7 @@
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.17",
|
||||
@@ -93,14 +99,14 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"adm-zip": "^0.5.17",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-es-x": "^9.6.0",
|
||||
"eslint-plugin-es-x": "^10.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -112,7 +118,8 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.26.0",
|
||||
"jsdom": "^30.0.0",
|
||||
"knip": "6.29.0",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
@@ -120,14 +127,15 @@
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-svgr": "^5.2.0"
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
"eslint --fix --max-warnings=0",
|
||||
"biome format --write"
|
||||
],
|
||||
"*.{css,scss,json,yaml,yml}": [
|
||||
"*.{css,scss,json}": [
|
||||
"biome format --write"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+1636
-1187
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,4 @@ allowBuilds:
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- dayjs@1.11.21
|
||||
- jsdom@30.0.0
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { request } from 'node:http'
|
||||
import { connect } from 'node:net'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_RETRY_DELAY_MS = 100
|
||||
const TRANSIENT_REQUEST_ERRORS = new Set([
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
])
|
||||
const SAFE_REQUEST_ERROR_CODES = new Set([
|
||||
...TRANSIENT_REQUEST_ERRORS,
|
||||
'EACCES',
|
||||
'EINVAL',
|
||||
'ENOTFOUND',
|
||||
])
|
||||
|
||||
function validateInstanceRecord(record) {
|
||||
if (
|
||||
!record ||
|
||||
!Number.isInteger(record.port) ||
|
||||
record.port < 1 ||
|
||||
record.port > 65_535
|
||||
) {
|
||||
throw new Error('invalid instance record port')
|
||||
}
|
||||
if (
|
||||
typeof record.token !== 'string' ||
|
||||
!/^[0-9a-f]{64}$/.test(record.token)
|
||||
) {
|
||||
throw new Error('invalid instance record token')
|
||||
}
|
||||
return { port: record.port, token: record.token }
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function safeRequestError(error) {
|
||||
const code = SAFE_REQUEST_ERROR_CODES.has(error?.code)
|
||||
? error.code
|
||||
: 'UNKNOWN'
|
||||
const cause = new Error(code)
|
||||
cause.code = code
|
||||
return new Error('dev quit request failed', { cause })
|
||||
}
|
||||
|
||||
export async function readPrivateInstanceRecord(path, expectedUid) {
|
||||
let handle
|
||||
try {
|
||||
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
||||
const stat = await handle.stat()
|
||||
if (!stat.isFile()) {
|
||||
throw new Error('instance record must be an ordinary file')
|
||||
}
|
||||
if (stat.uid !== expectedUid) {
|
||||
throw new Error('instance record has the wrong owner')
|
||||
}
|
||||
if ((stat.mode & 0o777) !== 0o600) {
|
||||
throw new Error('instance record permissions must be 0600')
|
||||
}
|
||||
const contents = await handle.readFile({ encoding: 'utf8' })
|
||||
let record
|
||||
try {
|
||||
record = JSON.parse(contents)
|
||||
} catch {
|
||||
throw new Error('instance record is not valid JSON')
|
||||
}
|
||||
return validateInstanceRecord(record)
|
||||
} finally {
|
||||
await handle?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function postDevQuit(record, timeoutMs, requestImpl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
callback(value)
|
||||
}
|
||||
const outgoing = requestImpl(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: record.port,
|
||||
path: '/commands/dev/quit',
|
||||
method: 'POST',
|
||||
agent: false,
|
||||
headers: {
|
||||
'X-Instance-Token': record.token,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
response.on('aborted', () => {})
|
||||
response.on('error', () => {})
|
||||
response.on('close', () => {})
|
||||
settle(resolve, response.statusCode)
|
||||
response.destroy()
|
||||
},
|
||||
)
|
||||
outgoing.once('error', (error) => settle(reject, error))
|
||||
outgoing.setTimeout(timeoutMs, () => {
|
||||
const error = new Error('dev quit request timed out')
|
||||
error.code = 'ETIMEDOUT'
|
||||
outgoing.destroy(error)
|
||||
})
|
||||
outgoing.end()
|
||||
})
|
||||
}
|
||||
|
||||
export async function requestDevQuit(
|
||||
recordOrReader,
|
||||
{
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
requestImpl = request,
|
||||
now = Date.now,
|
||||
sleep = delay,
|
||||
} = {},
|
||||
) {
|
||||
const deadline = now() + timeoutMs
|
||||
const readRecord =
|
||||
typeof recordOrReader === 'function'
|
||||
? recordOrReader
|
||||
: async () => recordOrReader
|
||||
|
||||
while (now() < deadline) {
|
||||
let record
|
||||
try {
|
||||
record = validateInstanceRecord(await readRecord())
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
|
||||
continue
|
||||
}
|
||||
|
||||
const remainingMs = Math.max(1, deadline - now())
|
||||
let status
|
||||
try {
|
||||
status = await postDevQuit(record, remainingMs, requestImpl)
|
||||
} catch (error) {
|
||||
if (!TRANSIENT_REQUEST_ERRORS.has(error?.code)) {
|
||||
throw safeRequestError(error)
|
||||
}
|
||||
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
|
||||
continue
|
||||
}
|
||||
if (status === 202) {
|
||||
return record
|
||||
}
|
||||
if (status !== 503) {
|
||||
throw new Error(
|
||||
`dev quit request returned HTTP ${status ?? 'without a status'}`,
|
||||
)
|
||||
}
|
||||
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
|
||||
}
|
||||
|
||||
throw new Error('timed out waiting for the dev quit request to be accepted')
|
||||
}
|
||||
|
||||
function portAcceptsConnections(port, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect({ host: '127.0.0.1', port })
|
||||
let settled = false
|
||||
const finish = (isOpen) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolve(isOpen)
|
||||
}
|
||||
socket.once('connect', () => finish(true))
|
||||
socket.once('error', () => finish(false))
|
||||
socket.setTimeout(Math.max(1, Math.min(1_000, timeoutMs)), () =>
|
||||
finish(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForPortClosed(
|
||||
port,
|
||||
{
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
probePort = portAcceptsConnections,
|
||||
now = Date.now,
|
||||
sleep = delay,
|
||||
} = {},
|
||||
) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('invalid loopback port')
|
||||
}
|
||||
const deadline = now() + timeoutMs
|
||||
while (now() < deadline) {
|
||||
const remainingMs = deadline - now()
|
||||
if (!(await probePort(port, remainingMs))) {
|
||||
return
|
||||
}
|
||||
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
|
||||
}
|
||||
throw new Error('timed out waiting for the dev endpoint to close')
|
||||
}
|
||||
|
||||
export function createMacosShutdownController({
|
||||
requestQuit,
|
||||
waitForPortClosed: waitForClosed,
|
||||
waitForChildExit,
|
||||
terminateGroup,
|
||||
finalizeGroup = terminateGroup,
|
||||
exit,
|
||||
report = (message) => console.error(message),
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
forceTimeoutMs = 2_000,
|
||||
now = Date.now,
|
||||
}) {
|
||||
let signalCount = 0
|
||||
let forced = false
|
||||
let forcedPromise
|
||||
let groupFinalizationPromise
|
||||
|
||||
const remainingBudget = (deadline) => {
|
||||
const remaining = deadline - now()
|
||||
if (remaining <= 0) {
|
||||
throw new Error('timed out waiting for graceful dev shutdown')
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
const finalizeOnce = (operation, budget) => {
|
||||
groupFinalizationPromise ??= Promise.resolve().then(() => operation(budget))
|
||||
return groupFinalizationPromise
|
||||
}
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
const deadline = now() + timeoutMs
|
||||
try {
|
||||
const record = await requestQuit(remainingBudget(deadline))
|
||||
if (forced) return false
|
||||
await waitForClosed(record.port, remainingBudget(deadline))
|
||||
if (forced) return false
|
||||
await waitForChildExit(remainingBudget(deadline))
|
||||
if (forced) return false
|
||||
await finalizeOnce(finalizeGroup, remainingBudget(deadline))
|
||||
if (forced) return false
|
||||
exit(130)
|
||||
return true
|
||||
} catch (error) {
|
||||
report(
|
||||
`Graceful dev shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSigint() {
|
||||
signalCount += 1
|
||||
if (signalCount === 1) {
|
||||
return gracefulShutdown()
|
||||
}
|
||||
if (!forcedPromise) {
|
||||
forced = true
|
||||
forcedPromise = finalizeOnce(terminateGroup, forceTimeoutMs).then(
|
||||
() => {
|
||||
exit(130)
|
||||
return true
|
||||
},
|
||||
)
|
||||
}
|
||||
return forcedPromise
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const developmentServiceDirectoryEnvironment =
|
||||
'CLASH_VERGE_DEV_SERVICE_DIR'
|
||||
const developmentServiceInstallerEnvironment =
|
||||
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const repositoryRoot = resolve(scriptDirectory, '..')
|
||||
const serviceRepository = resolve(
|
||||
repositoryRoot,
|
||||
'..',
|
||||
'clash-verge-service-ipc',
|
||||
)
|
||||
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
||||
const developmentReceipt = join(
|
||||
repositoryRoot,
|
||||
'target',
|
||||
'development-service.json',
|
||||
)
|
||||
|
||||
function executable(name, platform) {
|
||||
return join(
|
||||
serviceRepository,
|
||||
'target',
|
||||
'debug',
|
||||
platform === 'win32' ? `${name}.exe` : name,
|
||||
)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env ?? process.env,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
windowsHide: true,
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
const error = new Error(
|
||||
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`,
|
||||
)
|
||||
error.exitCode = code
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function hashFiles(paths) {
|
||||
const hash = createHash('sha256')
|
||||
for (const path of paths) {
|
||||
hash.update(await readFile(path))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function readInstalledFingerprint() {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
|
||||
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeInstalledFingerprint(fingerprint) {
|
||||
await mkdir(dirname(developmentReceipt), { recursive: true })
|
||||
await writeFile(
|
||||
developmentReceipt,
|
||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
export function windowsElevationInvocation(
|
||||
installer,
|
||||
environment = process.env,
|
||||
) {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
|
||||
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
|
||||
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
|
||||
'exit $child.ExitCode',
|
||||
].join('; ')
|
||||
return {
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||
env: {
|
||||
...environment,
|
||||
[developmentServiceInstallerEnvironment]: installer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function elevateInstaller(installer, platform) {
|
||||
if (platform === 'win32') {
|
||||
const invocation = windowsElevationInvocation(installer)
|
||||
return run(invocation.command, invocation.args, { env: invocation.env })
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const script =
|
||||
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
|
||||
return run('osascript', ['-e', script, installer, String(process.getgid())])
|
||||
}
|
||||
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||
return run(installer, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
|
||||
},
|
||||
})
|
||||
}
|
||||
try {
|
||||
await run('pkexec', [installer])
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
|
||||
await run('sudo', [installer])
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareDevelopmentService({
|
||||
platform = process.platform,
|
||||
} = {}) {
|
||||
await access(serviceManifest)
|
||||
await run('cargo', [
|
||||
'build',
|
||||
'--manifest-path',
|
||||
serviceManifest,
|
||||
'--features',
|
||||
'standalone,client,development-channel',
|
||||
'--bins',
|
||||
])
|
||||
|
||||
const service = executable('clash-verge-service', platform)
|
||||
await access(service)
|
||||
return dirname(service)
|
||||
}
|
||||
|
||||
export async function ensureDevelopmentService({
|
||||
platform = process.platform,
|
||||
} = {}) {
|
||||
const serviceDirectory = await prepareDevelopmentService({ platform })
|
||||
const service = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
|
||||
)
|
||||
const installer = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32'
|
||||
? 'clash-verge-service-install.exe'
|
||||
: 'clash-verge-service-install',
|
||||
)
|
||||
const driver = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32'
|
||||
? 'service-integration-driver.exe'
|
||||
: 'service-integration-driver',
|
||||
)
|
||||
const fingerprint = await hashFiles([service, installer])
|
||||
const installedFingerprint = await readInstalledFingerprint()
|
||||
|
||||
if (installedFingerprint === fingerprint) {
|
||||
try {
|
||||
await run(driver, ['probe'], { stdio: 'ignore' })
|
||||
return serviceDirectory
|
||||
} catch {
|
||||
// The expected binary is installed but unavailable; the repair tool owns recovery.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await elevateInstaller(installer, platform)
|
||||
} catch (error) {
|
||||
if (error.exitCode !== 75) throw error
|
||||
}
|
||||
await run(driver, ['ready'])
|
||||
await writeInstalledFingerprint(fingerprint)
|
||||
return serviceDirectory
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
import {
|
||||
createMacosShutdownController,
|
||||
readPrivateInstanceRecord,
|
||||
requestDevQuit,
|
||||
waitForPortClosed,
|
||||
} from './dev-control.mjs'
|
||||
import {
|
||||
developmentServiceDirectoryEnvironment,
|
||||
ensureDevelopmentService,
|
||||
prepareDevelopmentService,
|
||||
} from './dev-service.mjs'
|
||||
|
||||
const FORCE_GRACE_MS = 2_000
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
const FORCE_POLL_MS = 50
|
||||
const ANCHOR_EXIT_RESERVE_MS = 100
|
||||
|
||||
export function buildTauriInvocation(
|
||||
mode,
|
||||
environment = process.env,
|
||||
platform = process.platform,
|
||||
) {
|
||||
const env = { ...environment, RUST_BACKTRACE: 'full' }
|
||||
const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev'
|
||||
const args = ['exec', 'tauri', 'dev', '-f', features]
|
||||
if (mode === 'trace') {
|
||||
env.RUSTFLAGS = '--cfg tokio_unstable'
|
||||
args.push('tokio-trace')
|
||||
}
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args,
|
||||
env,
|
||||
detached: platform === 'darwin',
|
||||
}
|
||||
}
|
||||
|
||||
function wait(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function childLifecycle(child) {
|
||||
const spawned = new Promise((resolve, reject) => {
|
||||
child.once('spawn', resolve)
|
||||
child.once('error', reject)
|
||||
})
|
||||
spawned.catch(() => {})
|
||||
const result = new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }))
|
||||
})
|
||||
result.catch(() => {})
|
||||
return { result, spawned }
|
||||
}
|
||||
|
||||
function waitForPromise(promise, timeoutMs, message) {
|
||||
if (timeoutMs <= 0) return Promise.reject(new Error(message))
|
||||
let timeout
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
}),
|
||||
]).finally(() => clearTimeout(timeout))
|
||||
}
|
||||
|
||||
function signalDetachedGroup(childPid, signal, signalGroup) {
|
||||
if (!Number.isInteger(childPid) || childPid <= 0) {
|
||||
throw new Error('dev command did not provide a valid child PID')
|
||||
}
|
||||
try {
|
||||
signalGroup(-childPid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function terminateDetachedGroup(
|
||||
childPid,
|
||||
{
|
||||
signalGroup = process.kill.bind(process),
|
||||
sleep = wait,
|
||||
now = Date.now,
|
||||
graceMs = FORCE_GRACE_MS,
|
||||
pollMs = FORCE_POLL_MS,
|
||||
} = {},
|
||||
) {
|
||||
if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return
|
||||
|
||||
const deadline = now() + graceMs
|
||||
while (now() < deadline) {
|
||||
await sleep(Math.min(pollMs, deadline - now()))
|
||||
if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
signalDetachedGroup(childPid, 'SIGKILL', signalGroup)
|
||||
}
|
||||
|
||||
export function mirrorChildExit(
|
||||
{ code, signal },
|
||||
{
|
||||
exit = (exitCode) => process.exit(exitCode),
|
||||
signalSelf = (childSignal) => {
|
||||
process.removeAllListeners(childSignal)
|
||||
process.kill(process.pid, childSignal)
|
||||
},
|
||||
} = {},
|
||||
) {
|
||||
if (code !== null) {
|
||||
exit(code)
|
||||
return
|
||||
}
|
||||
if (signal) {
|
||||
signalSelf(signal)
|
||||
return
|
||||
}
|
||||
exit(1)
|
||||
}
|
||||
|
||||
function defaultInstallSigint(handler) {
|
||||
process.on('SIGINT', handler)
|
||||
return () => process.off('SIGINT', handler)
|
||||
}
|
||||
|
||||
function defaultInstallSigterm(handler) {
|
||||
process.on('SIGTERM', handler)
|
||||
return () => process.off('SIGTERM', handler)
|
||||
}
|
||||
|
||||
function defaultInstallMessage(handler) {
|
||||
process.on('message', handler)
|
||||
return () => process.off('message', handler)
|
||||
}
|
||||
|
||||
function defaultInstallDisconnect(handler) {
|
||||
process.on('disconnect', handler)
|
||||
return () => process.off('disconnect', handler)
|
||||
}
|
||||
|
||||
function sendToParent(message) {
|
||||
if (process.connected && process.send) process.send(message, () => {})
|
||||
}
|
||||
|
||||
export async function runDevAnchor(mode, dependencies = {}) {
|
||||
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
|
||||
throw new Error('usage: node scripts/dev.mjs --anchor <dev|trace|sidecar>')
|
||||
}
|
||||
|
||||
const environment = dependencies.environment ?? process.env
|
||||
const spawnChild = dependencies.spawnChild ?? spawn
|
||||
const send = dependencies.send ?? sendToParent
|
||||
const exit = dependencies.exit ?? ((code) => process.exit(code))
|
||||
const report = dependencies.report ?? ((message) => console.error(message))
|
||||
const ownPid = dependencies.ownPid ?? process.pid
|
||||
const terminateGroup =
|
||||
dependencies.terminateGroup ??
|
||||
((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS }))
|
||||
let released = false
|
||||
let disconnectCleanup
|
||||
|
||||
const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm
|
||||
installSigterm(() => {})
|
||||
const installMessage = dependencies.installMessage ?? defaultInstallMessage
|
||||
installMessage((message) => {
|
||||
if (message?.type === 'release' && !disconnectCleanup) {
|
||||
released = true
|
||||
exit(0)
|
||||
}
|
||||
})
|
||||
const installDisconnect =
|
||||
dependencies.installDisconnect ?? defaultInstallDisconnect
|
||||
installDisconnect(() => {
|
||||
if (released || disconnectCleanup) return
|
||||
disconnectCleanup = Promise.resolve()
|
||||
.then(() => terminateGroup(ownPid))
|
||||
.then(() => exit(1))
|
||||
.catch((error) => {
|
||||
report(`Dev anchor cleanup failed: ${error.message}`)
|
||||
exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
const invocation = buildTauriInvocation(mode, environment, 'darwin')
|
||||
let inner
|
||||
try {
|
||||
inner = spawnChild(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
detached: false,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
} catch {
|
||||
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
|
||||
return
|
||||
}
|
||||
if (!inner || typeof inner.once !== 'function') {
|
||||
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
|
||||
return
|
||||
}
|
||||
|
||||
const innerLifecycle = childLifecycle(inner)
|
||||
try {
|
||||
await innerLifecycle.spawned
|
||||
} catch {
|
||||
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
|
||||
return
|
||||
}
|
||||
send({ type: 'ready' })
|
||||
innerLifecycle.result.then(
|
||||
({ code, signal }) => send({ type: 'inner-result', code, signal }),
|
||||
() => send({ type: 'inner-error', code: 'INNER_FAILED' }),
|
||||
)
|
||||
}
|
||||
|
||||
function createAnchorChannel(anchor, anchorResult) {
|
||||
let innerSettled = false
|
||||
let readySettled = false
|
||||
let resolveResult
|
||||
let rejectResult
|
||||
let resolveReady
|
||||
let rejectReady
|
||||
const innerResult = new Promise((resolve, reject) => {
|
||||
resolveResult = resolve
|
||||
rejectResult = reject
|
||||
})
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
resolveReady = resolve
|
||||
rejectReady = reject
|
||||
})
|
||||
const settleInner = (callback, value) => {
|
||||
if (innerSettled) return
|
||||
innerSettled = true
|
||||
callback(value)
|
||||
}
|
||||
const settleReady = (callback, value) => {
|
||||
if (readySettled) return
|
||||
readySettled = true
|
||||
callback(value)
|
||||
}
|
||||
anchor.on('message', (message) => {
|
||||
if (message?.type === 'ready') {
|
||||
settleReady(resolveReady)
|
||||
} else if (message?.type === 'inner-result') {
|
||||
settleInner(resolveResult, {
|
||||
code: message.code,
|
||||
signal: message.signal,
|
||||
})
|
||||
} else if (message?.type === 'inner-error') {
|
||||
const error = new Error(
|
||||
`inner dev command failed (${message.code ?? 'UNKNOWN'})`,
|
||||
)
|
||||
settleReady(rejectReady, error)
|
||||
settleInner(rejectResult, error)
|
||||
}
|
||||
})
|
||||
anchorResult.then(
|
||||
() => {
|
||||
const error = new Error(
|
||||
'dev group anchor exited before the inner command completed',
|
||||
)
|
||||
settleReady(rejectReady, error)
|
||||
settleInner(rejectResult, error)
|
||||
},
|
||||
() => {
|
||||
const error = new Error('dev group anchor failed')
|
||||
settleReady(rejectReady, error)
|
||||
settleInner(rejectResult, error)
|
||||
},
|
||||
)
|
||||
ready.catch(() => {})
|
||||
innerResult.catch(() => {})
|
||||
return { innerResult, ready }
|
||||
}
|
||||
|
||||
function sendRelease(anchor) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof anchor.send !== 'function') {
|
||||
reject(new Error('dev group anchor IPC is unavailable'))
|
||||
return
|
||||
}
|
||||
anchor.send({ type: 'release' }, (error) => {
|
||||
if (error) reject(new Error('dev group anchor release failed'))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function unsafeAnchorError() {
|
||||
return new Error('dev group anchor identity is unsafe')
|
||||
}
|
||||
|
||||
export async function runDevCommand(mode, dependencies = {}) {
|
||||
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
|
||||
throw new Error('usage: node scripts/dev.mjs <dev|trace|sidecar>')
|
||||
}
|
||||
|
||||
const platform = dependencies.platform ?? process.platform
|
||||
const environment = dependencies.environment ?? process.env
|
||||
const spawnChild = dependencies.spawnChild ?? spawn
|
||||
const exit = dependencies.exit ?? ((code) => process.exit(code))
|
||||
const report = dependencies.report ?? ((message) => console.error(message))
|
||||
const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit
|
||||
let recordPath
|
||||
let pendingSigints = 0
|
||||
let dispatchSigint = () => {
|
||||
pendingSigints += 1
|
||||
}
|
||||
let removeSigint = () => {}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
if (!environment.HOME) {
|
||||
throw new Error('HOME is required to locate the dev instance record')
|
||||
}
|
||||
recordPath = join(
|
||||
environment.HOME,
|
||||
'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json',
|
||||
)
|
||||
const installSigint = dependencies.installSigint ?? defaultInstallSigint
|
||||
removeSigint = installSigint(() => dispatchSigint()) ?? (() => {})
|
||||
}
|
||||
|
||||
const tauriInvocation = buildTauriInvocation(mode, environment, platform)
|
||||
const anchorInvocation = {
|
||||
command: dependencies.execPath ?? process.execPath,
|
||||
args: [fileURLToPath(import.meta.url), '--anchor', mode],
|
||||
env: tauriInvocation.env,
|
||||
detached: true,
|
||||
}
|
||||
const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation
|
||||
let child
|
||||
try {
|
||||
child = spawnChild(invocation.command, invocation.args, {
|
||||
shell: platform === 'win32',
|
||||
env: invocation.env,
|
||||
detached: invocation.detached,
|
||||
stdio:
|
||||
platform === 'darwin'
|
||||
? ['inherit', 'inherit', 'inherit', 'ipc']
|
||||
: 'inherit',
|
||||
})
|
||||
} catch (error) {
|
||||
removeSigint()
|
||||
throw error
|
||||
}
|
||||
if (!child || typeof child.once !== 'function') {
|
||||
removeSigint()
|
||||
throw new Error('dev command failed to return a child process')
|
||||
}
|
||||
const outerLifecycle = childLifecycle(child)
|
||||
|
||||
if (platform !== 'darwin') {
|
||||
mirrorResult(await outerLifecycle.result)
|
||||
return
|
||||
}
|
||||
|
||||
let groupState = 'safe'
|
||||
let finalKillSent = false
|
||||
const anchorChannel = createAnchorChannel(child, outerLifecycle.result)
|
||||
outerLifecycle.result.then(
|
||||
() => {
|
||||
if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe'
|
||||
},
|
||||
() => {
|
||||
groupState = 'unsafe'
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await outerLifecycle.spawned
|
||||
if (!Number.isInteger(child.pid) || child.pid <= 0) {
|
||||
throw new Error('dev command did not provide a valid child PID')
|
||||
}
|
||||
await waitForPromise(
|
||||
anchorChannel.ready,
|
||||
SHUTDOWN_TIMEOUT_MS,
|
||||
'timed out waiting for the dev group anchor to initialize',
|
||||
)
|
||||
} catch (error) {
|
||||
removeSigint()
|
||||
throw error
|
||||
}
|
||||
|
||||
const anchorPid = child.pid
|
||||
const innerResult = anchorChannel.innerResult
|
||||
let intercepted = false
|
||||
let observedShutdownOperation
|
||||
let anchorFinalization
|
||||
|
||||
const signalGroup = dependencies.signalGroup ?? process.kill.bind(process)
|
||||
const guardedSignalGroup = (pid, signal) => {
|
||||
if (groupState === 'unsafe') throw unsafeAnchorError()
|
||||
if (signal === 'SIGKILL') finalKillSent = true
|
||||
try {
|
||||
signalGroup(pid, signal)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
groupState = 'unsafe'
|
||||
throw unsafeAnchorError()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const assertGroupAbsent = () => {
|
||||
try {
|
||||
signalGroup(-anchorPid, 0)
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') return
|
||||
throw new Error('unable to verify that the dev process group exited', {
|
||||
cause: error,
|
||||
})
|
||||
}
|
||||
throw new Error('dev process group still exists after anchor exit')
|
||||
}
|
||||
|
||||
const forceAnchor = async (budget) => {
|
||||
if (groupState === 'unsafe') throw unsafeAnchorError()
|
||||
groupState = 'terminating'
|
||||
if (dependencies.terminateGroup) {
|
||||
await dependencies.terminateGroup(anchorPid, budget)
|
||||
return
|
||||
}
|
||||
const deadline = Date.now() + budget
|
||||
await terminateDetachedGroup(anchorPid, {
|
||||
graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS),
|
||||
signalGroup: guardedSignalGroup,
|
||||
})
|
||||
await waitForPromise(
|
||||
outerLifecycle.result,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
'timed out waiting for the dev group anchor to exit',
|
||||
)
|
||||
}
|
||||
|
||||
const releaseAnchor = async (budget) => {
|
||||
if (groupState === 'unsafe') throw unsafeAnchorError()
|
||||
groupState = 'released'
|
||||
const deadline = Date.now() + budget
|
||||
await waitForPromise(
|
||||
sendRelease(child),
|
||||
Math.max(1, deadline - Date.now()),
|
||||
'timed out releasing the dev group anchor',
|
||||
)
|
||||
await waitForPromise(
|
||||
outerLifecycle.result,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
'timed out waiting for the released dev group anchor',
|
||||
)
|
||||
assertGroupAbsent()
|
||||
}
|
||||
|
||||
const finalizeAnchorOnce = (kind, budget) => {
|
||||
anchorFinalization ??=
|
||||
kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget)
|
||||
return anchorFinalization
|
||||
}
|
||||
|
||||
const getUid = dependencies.getUid ?? (() => process.getuid())
|
||||
const requestQuit =
|
||||
dependencies.requestQuit ??
|
||||
((budget) =>
|
||||
requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), {
|
||||
timeoutMs: budget,
|
||||
}))
|
||||
|
||||
const controller = createMacosShutdownController({
|
||||
requestQuit,
|
||||
waitForPortClosed: (port, budget) =>
|
||||
(dependencies.waitForPortClosed ?? waitForPortClosed)(port, {
|
||||
timeoutMs: budget,
|
||||
}),
|
||||
waitForChildExit: (budget) =>
|
||||
waitForPromise(
|
||||
innerResult,
|
||||
budget,
|
||||
'timed out waiting for the inner dev command to exit',
|
||||
),
|
||||
finalizeGroup: (budget) => finalizeAnchorOnce('release', budget),
|
||||
terminateGroup: (budget) => finalizeAnchorOnce('force', budget),
|
||||
exit,
|
||||
report,
|
||||
timeoutMs: SHUTDOWN_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
dispatchSigint = () => {
|
||||
intercepted = true
|
||||
const operation = controller.handleSigint()
|
||||
if (operation !== observedShutdownOperation) {
|
||||
observedShutdownOperation = operation
|
||||
void operation.catch((error) => {
|
||||
report(`Forced dev shutdown failed: ${error.message}`)
|
||||
exit(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
const queuedSigints = pendingSigints
|
||||
pendingSigints = 0
|
||||
for (let index = 0; index < queuedSigints; index += 1) {
|
||||
dispatchSigint()
|
||||
}
|
||||
|
||||
innerResult.then(
|
||||
async (result) => {
|
||||
if (intercepted) return
|
||||
try {
|
||||
await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS)
|
||||
if (!intercepted) mirrorResult(result)
|
||||
} catch (error) {
|
||||
if (!intercepted) {
|
||||
report(`Dev command failed: ${error.message}`)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!intercepted) {
|
||||
report(`Dev command failed: ${error.message}`)
|
||||
exit(1)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function prepareDevRuntime(mode, dependencies = {}) {
|
||||
const ensureService = dependencies.ensureService ?? ensureDevelopmentService
|
||||
const prepareService =
|
||||
dependencies.prepareService ?? prepareDevelopmentService
|
||||
let serviceDirectory
|
||||
if (mode === 'service') {
|
||||
serviceDirectory = await ensureService()
|
||||
} else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') {
|
||||
serviceDirectory = await prepareService()
|
||||
}
|
||||
return {
|
||||
mode: mode === 'service' ? 'dev' : mode,
|
||||
serviceDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (process.argv[2] === '--anchor') {
|
||||
await runDevAnchor(process.argv[3])
|
||||
} else {
|
||||
const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2])
|
||||
if (serviceDirectory) {
|
||||
process.env[developmentServiceDirectoryEnvironment] = serviceDirectory
|
||||
}
|
||||
await runDevCommand(mode)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
pathToFileURL(process.argv[1]).href === import.meta.url
|
||||
) {
|
||||
run().catch((error) => {
|
||||
console.error(`Unable to start dev command: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
+10
-63
@@ -10,6 +10,7 @@ import { glob } from 'glob'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import { extract } from 'tar'
|
||||
|
||||
import { resolveServiceRelease } from './service-release.mjs'
|
||||
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
|
||||
|
||||
/**
|
||||
@@ -577,12 +578,6 @@ const resolveServicePermission = async () => {
|
||||
// =======================
|
||||
// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback)
|
||||
// =======================
|
||||
const SERVICE_LATEST_URL =
|
||||
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/latest'
|
||||
const SERVICE_URL_PREFIX =
|
||||
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
|
||||
let SERVICE_VERSION
|
||||
|
||||
const SERVICE_BINARIES = [
|
||||
'clash-verge-service',
|
||||
'clash-verge-service-install',
|
||||
@@ -598,53 +593,6 @@ function serviceFileInfo(name) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseServiceVersionFromUrl(url) {
|
||||
const match = url.match(/\/releases\/tag\/([^/?#]+)/)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
async function getLatestServiceVersion() {
|
||||
if (!FORCE) {
|
||||
const cached = await getCachedVersion('SERVICE_VERSION')
|
||||
if (cached) {
|
||||
SERVICE_VERSION = cached
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const options = {}
|
||||
const httpProxy =
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy
|
||||
if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy)
|
||||
|
||||
try {
|
||||
const response = await fetch(SERVICE_LATEST_URL, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
redirect: 'follow',
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Failed to fetch ${SERVICE_LATEST_URL}: ${response.status}`,
|
||||
)
|
||||
|
||||
SERVICE_VERSION = parseServiceVersionFromUrl(response.url)
|
||||
if (!SERVICE_VERSION)
|
||||
throw new Error(
|
||||
`Unable to resolve service release tag from ${response.url}`,
|
||||
)
|
||||
|
||||
log_info(`Latest service version: ${SERVICE_VERSION}`)
|
||||
await setCachedVersion('SERVICE_VERSION', SERVICE_VERSION)
|
||||
} catch (err) {
|
||||
log_error('Error fetching latest service version:', err.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function findExtractedFile(dir, fileName) {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
@@ -667,16 +615,15 @@ async function resolveServiceBundle() {
|
||||
}
|
||||
})
|
||||
|
||||
if (!FORCE && files.every(({ targetPath }) => fs.existsSync(targetPath))) {
|
||||
log_success('"clash-verge-service-ipc" already exists, skipping download')
|
||||
return
|
||||
}
|
||||
|
||||
await getLatestServiceVersion()
|
||||
|
||||
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
|
||||
const archiveFile = `clash-verge-service-ipc-${SERVICE_VERSION}-${SIDECAR_HOST}.${archiveExt}`
|
||||
const downloadURL = `${SERVICE_URL_PREFIX}/${SERVICE_VERSION}/${archiveFile}`
|
||||
const cargoManifest = await fsp.readFile(
|
||||
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
||||
'utf8',
|
||||
)
|
||||
const { archiveFile, downloadURL } = resolveServiceRelease(
|
||||
cargoManifest,
|
||||
SIDECAR_HOST,
|
||||
platform,
|
||||
)
|
||||
const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc')
|
||||
const tempArchive = path.join(tempDir, archiveFile)
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const SERVICE_URL_PREFIX =
|
||||
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
|
||||
|
||||
export function resolveServiceRelease(cargoManifest, host, platform) {
|
||||
const dependency = cargoManifest
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
|
||||
const packageVersion = dependency?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
|
||||
if (!packageVersion) {
|
||||
throw new Error(
|
||||
'clash_verge_service_ipc dependency must declare an inline version',
|
||||
)
|
||||
}
|
||||
|
||||
const version = `v${packageVersion}`
|
||||
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
|
||||
const archiveFile = `clash-verge-service-ipc-${version}-${host}.${archiveExt}`
|
||||
return {
|
||||
version,
|
||||
archiveFile,
|
||||
downloadURL: `${SERVICE_URL_PREFIX}/${version}/${archiveFile}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import { resolveServiceRelease } from './service-release.mjs'
|
||||
|
||||
test('service prebuild release follows the Cargo dependency version', async () => {
|
||||
const cargoManifest = await readFile(
|
||||
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const dependencyVersion = cargoManifest
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.startsWith('clash_verge_service_ipc ='))
|
||||
?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
|
||||
|
||||
assert.ok(dependencyVersion)
|
||||
const releaseVersion = `v${dependencyVersion}`
|
||||
assert.deepEqual(
|
||||
resolveServiceRelease(cargoManifest, 'x86_64-pc-windows-msvc', 'win32'),
|
||||
{
|
||||
version: releaseVersion,
|
||||
archiveFile: `clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
|
||||
downloadURL: `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${releaseVersion}/clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('service prebuild rejects a dependency without an explicit version', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveServiceRelease(
|
||||
'clash_verge_service_ipc = { path = "../service" }',
|
||||
'x86_64-pc-windows-msvc',
|
||||
'win32',
|
||||
),
|
||||
/must declare an inline version/,
|
||||
)
|
||||
})
|
||||
+14
-10
@@ -33,7 +33,7 @@ async function sendTelegramNotification() {
|
||||
log_info(`Download URL: ${downloadUrl}`)
|
||||
|
||||
// 读取发布说明和下载地址
|
||||
let releaseContent = ''
|
||||
let releaseContent
|
||||
try {
|
||||
releaseContent = readFileSync('release.txt', 'utf-8')
|
||||
log_info('成功读取 release.txt 文件')
|
||||
@@ -92,15 +92,19 @@ async function sendTelegramNotification() {
|
||||
function sanitizeTelegramHTML(content) {
|
||||
// Telegram supports: b, strong, i, em, u, ins, s, strike, del,
|
||||
// a, code, pre, blockquote, tg-spoiler, tg-emoji
|
||||
const allowedTags =
|
||||
/^\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(\s|>|$)/i
|
||||
return content.replace(/<\/?[^>]*>/g, (tag) => {
|
||||
const inner = tag.replace(/^<\/?/, '').replace(/>$/, '')
|
||||
if (allowedTags.test(inner) || allowedTags.test(tag.slice(1))) {
|
||||
return tag
|
||||
}
|
||||
// Escape unsupported tags so they display as text
|
||||
return tag.replace(/</g, '<').replace(/>/g, '>')
|
||||
const allowedTag =
|
||||
/^<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?:\s[^<>]*)?>$/i
|
||||
// Match a well-formed tag (name first, no nested angle brackets) OR a lone
|
||||
// '<'/'>'. Matching lone brackets is what keeps a stray '<' in the changelog
|
||||
// (e.g. "原始配置 < Merge") from greedily swallowing the next real tag and
|
||||
// leaving its closing tag orphaned ("Unexpected end tag").
|
||||
return content.replace(/<\/?[a-z][^<>]*>|[<>]/gi, (token) => {
|
||||
if (token === '<') return '<'
|
||||
if (token === '>') return '>'
|
||||
// Keep supported tags; escape everything else so it displays as text.
|
||||
return allowedTag.test(token)
|
||||
? token
|
||||
: token.replace(/</g, '<').replace(/>/g, '>')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+27
-26
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.2"
|
||||
version = "2.5.3"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -17,7 +17,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
verge-dev = ["clash_verge_logger/color"]
|
||||
verge-dev = ["clash_verge_logger/color", "clash_verge_service_ipc/development-channel"]
|
||||
dev-sidecar = ["verge-dev"]
|
||||
tauri-dev = []
|
||||
tokio-trace = ["console-subscriber"]
|
||||
clippy = ["tauri/test"]
|
||||
@@ -55,63 +56,59 @@ serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
smartstring = { workspace = true, features = ["serde"] }
|
||||
bitflags = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
warp = { version = "0.4.3", features = ["server"] }
|
||||
open = "5.3.6"
|
||||
open = "5.4.0"
|
||||
dunce = "1.0.5"
|
||||
nanoid = "0.5"
|
||||
chrono = "0.4.45"
|
||||
boa_engine = "0.21.1"
|
||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { version = "0.13.4", features = [
|
||||
"json",
|
||||
"cookies",
|
||||
"gzip",
|
||||
"rustls",
|
||||
"form",
|
||||
] }
|
||||
regex = "1.12.4"
|
||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
|
||||
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
||||
regex = "1.13.1"
|
||||
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "main", features = [
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.6.0"
|
||||
reqwest_dav = "0.3.3"
|
||||
serde-xml-rs = "0.6.0"
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.0"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.32"
|
||||
futures = "0.3.33"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.5.9"
|
||||
console-subscriber = { version = "0.5.0", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.1.0" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "refactor-use-reqwest-ipc" }
|
||||
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
|
||||
async-trait = "0.1.89"
|
||||
clash_verge_service_ipc = { version = "2.3.3", features = [
|
||||
async-trait = "0.1.91"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.1", version = "2.6.1", features = [
|
||||
"client",
|
||||
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.41", features = ["ring"] }
|
||||
webpki-roots = "1.0.8"
|
||||
rustls = { version = "0.23.42", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
rust_iso3166 = "0.2.0"
|
||||
# Use the git repo until the next release after v2.0.0.
|
||||
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
|
||||
windows-sys.workspace = true
|
||||
bytes = "1.12.0"
|
||||
tokio-util = { version = "0.7.18", features = ["time"] }
|
||||
bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.189"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.4"
|
||||
@@ -140,7 +137,9 @@ objc2-app-kit = { version = "0.3.2", features = [
|
||||
deelevate = { workspace = true }
|
||||
runas = "=1.2.0"
|
||||
winreg = "0.56.0"
|
||||
windows-service = "0.8.1"
|
||||
windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
||||
windows-sys.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
@@ -149,6 +148,8 @@ tauri-plugin-updater = "2.10.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
warp = { version = "0.4.3", features = ["test"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+33
-1
@@ -1,4 +1,34 @@
|
||||
fn main() {
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
|
||||
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
|
||||
{
|
||||
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
|
||||
// Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
|
||||
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
|
||||
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
r#"<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
"#,
|
||||
)?;
|
||||
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
|
||||
}
|
||||
|
||||
#[cfg(feature = "clippy")]
|
||||
{
|
||||
println!("cargo:warning=Skipping tauri_build during Clippy");
|
||||
@@ -6,4 +36,6 @@ fn main() {
|
||||
|
||||
#[cfg(not(feature = "clippy"))]
|
||||
tauri_build::build();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download-and-install",
|
||||
"process:allow-restart",
|
||||
"deep-link:default",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
"global-shortcut:allow-register-all",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-unregister-all",
|
||||
"process:allow-restart",
|
||||
"process:allow-exit",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
|
||||
@@ -478,7 +478,22 @@ Function .onInit
|
||||
${EndIf}
|
||||
|
||||
!if "${DISPLAYLANGUAGESELECTOR}" == "true"
|
||||
!insertmacro MUI_LANGDLL_DISPLAY
|
||||
; Auto-update forwards the app's UI language as `/LANG=<NSIS-lang-id>` so
|
||||
; the installer uses it directly and skips the interactive language
|
||||
; selector, letting the update start without prompting the user.
|
||||
; See `src-tauri/src/core/updater.rs` (`nsis_language_id`).
|
||||
${GetOptions} $CMDLINE "/LANG=" $0
|
||||
${IfNot} ${Errors}
|
||||
${If} $0 == "1033"
|
||||
${OrIf} $0 == "1049"
|
||||
${OrIf} $0 == "2052"
|
||||
StrCpy $LANGUAGE $0
|
||||
${Else}
|
||||
!insertmacro MUI_LANGDLL_DISPLAY
|
||||
${EndIf}
|
||||
${Else}
|
||||
!insertmacro MUI_LANGDLL_DISPLAY
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
!insertmacro SetContext
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::CmdResult;
|
||||
use super::{CmdResult, WithErrorCode as _, coded_error};
|
||||
use crate::feat;
|
||||
use crate::utils::{dirs, yaml_emitter};
|
||||
use crate::{
|
||||
@@ -32,7 +32,9 @@ pub async fn get_clash_info() -> CmdResult<ClashInfo> {
|
||||
/// 修改Clash配置
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
feat::patch_clash(&payload).await.stringify_err()
|
||||
feat::patch_clash(&payload)
|
||||
.await
|
||||
.with_error_code("CLASH_CONFIG_UPDATE_FAILED")
|
||||
}
|
||||
|
||||
/// 修改Clash模式
|
||||
@@ -41,7 +43,9 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
feat::change_clash_mode(payload).await
|
||||
feat::change_clash_mode(payload)
|
||||
.await
|
||||
.with_error_code("CLASH_MODE_UPDATE_FAILED")
|
||||
}
|
||||
|
||||
/// 获取当前 Clash 模式(容错读取)
|
||||
@@ -74,7 +78,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
||||
let error_msg: String = format!("Core changed but failed to restart: {err}").into();
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||
logging!(error, Type::Core, "{error_msg}");
|
||||
Ok(Some(error_msg))
|
||||
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
||||
let error_msg: String = err;
|
||||
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||
Ok(Some(error_msg))
|
||||
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +94,10 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
|
||||
/// 启动核心
|
||||
#[tauri::command]
|
||||
pub async fn start_core() -> CmdResult {
|
||||
let result = CoreManager::global().start_core().await.stringify_err();
|
||||
let result = CoreManager::global()
|
||||
.start_core()
|
||||
.await
|
||||
.with_error_code("CORE_START_FAILED");
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
@@ -101,7 +108,10 @@ pub async fn start_core() -> CmdResult {
|
||||
#[tauri::command]
|
||||
pub async fn stop_core() -> CmdResult {
|
||||
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
|
||||
let result = CoreManager::global().stop_core().await.stringify_err();
|
||||
let result = CoreManager::global()
|
||||
.stop_core()
|
||||
.await
|
||||
.with_error_code("CORE_STOP_FAILED");
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
@@ -112,7 +122,10 @@ pub async fn stop_core() -> CmdResult {
|
||||
#[tauri::command]
|
||||
pub async fn restart_core() -> CmdResult {
|
||||
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
|
||||
let result = CoreManager::global().restart_core().await.stringify_err();
|
||||
let result = CoreManager::global()
|
||||
.restart_core()
|
||||
.await
|
||||
.with_error_code("CORE_RESTART_FAILED");
|
||||
if result.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use super::{CmdResult, StringifyErr as _};
|
||||
use crate::{
|
||||
core::listener::{ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome},
|
||||
feat,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
|
||||
feat::probe_listener(request).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
|
||||
feat::save_proxy_ports(settings).await.stringify_err()
|
||||
}
|
||||
@@ -3,11 +3,18 @@ use smartstring::alias::String;
|
||||
|
||||
pub type CmdResult<T = ()> = Result<T, String>;
|
||||
|
||||
const USER_ERROR_PREFIX: &str = "CVR_ERROR:";
|
||||
|
||||
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> String {
|
||||
format!("{USER_ERROR_PREFIX}{code}\n{detail}").into()
|
||||
}
|
||||
|
||||
// Command modules
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod clash;
|
||||
pub mod lightweight;
|
||||
pub mod listener;
|
||||
pub mod media_unlock_checker;
|
||||
pub mod network;
|
||||
pub mod profile;
|
||||
@@ -26,6 +33,7 @@ pub use app::*;
|
||||
pub use backup::*;
|
||||
pub use clash::*;
|
||||
pub use lightweight::*;
|
||||
pub use listener::*;
|
||||
pub use media_unlock_checker::*;
|
||||
pub use network::*;
|
||||
pub use profile::*;
|
||||
@@ -46,6 +54,10 @@ pub trait StringifyErr<T> {
|
||||
F: Fn(&str);
|
||||
}
|
||||
|
||||
pub trait WithErrorCode<T> {
|
||||
fn with_error_code(self, code: &str) -> CmdResult<T>;
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
|
||||
fn stringify_err(self) -> CmdResult<T> {
|
||||
self.map_err(|e| e.to_string().into())
|
||||
@@ -62,3 +74,22 @@ impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
|
||||
fn with_error_code(self, code: &str) -> CmdResult<T> {
|
||||
self.map_err(|error| coded_error(code, error))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::coded_error;
|
||||
|
||||
#[test]
|
||||
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
|
||||
assert_eq!(
|
||||
coded_error("CORE_RESTART_FAILED", "connection refused"),
|
||||
"CVR_ERROR:CORE_RESTART_FAILED\nconnection refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use clash_verge_logging::{Type, logging};
|
||||
use gethostname::gethostname;
|
||||
use network_interface::NetworkInterface;
|
||||
use serde_yaml_ng::Mapping;
|
||||
use std::net::TcpListener;
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
use tauri_plugin_clash_verge_sysinfo;
|
||||
|
||||
@@ -60,6 +59,11 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_embedded_server_port() -> CmdResult<u16> {
|
||||
crate::utils::server::embedded_server_port().stringify_err()
|
||||
}
|
||||
|
||||
/// 获取系统主机名
|
||||
#[tauri::command]
|
||||
pub fn get_system_hostname() -> String {
|
||||
@@ -99,8 +103,3 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_port_in_use(port: u16) -> bool {
|
||||
TcpListener::bind(("127.0.0.1", port)).is_err()
|
||||
}
|
||||
|
||||
+134
-43
@@ -1,5 +1,4 @@
|
||||
use super::CmdResult;
|
||||
use super::StringifyErr as _;
|
||||
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
||||
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
||||
use crate::config::profiles;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
@@ -21,7 +20,6 @@ use clash_verge_logging::{Type, logging, logging_error};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -61,7 +59,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "{}", e);
|
||||
Err(e.to_string().into())
|
||||
Err(coded_error("PROFILE_ENHANCE_FAILED", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,18 +77,18 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
|
||||
return Err(profile_import_error(&e).into());
|
||||
return Err(coded_error("PROFILE_IMPORT_FAILED", profile_import_error(&e)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = profiles_append_item_safe(item).await {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
|
||||
}
|
||||
|
||||
if let Err(e) = profiles_save_file_safe().await {
|
||||
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
||||
return Err(format!("导入订阅失败: {}", e).into());
|
||||
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
|
||||
}
|
||||
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
@@ -114,7 +112,7 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::Cmd, "重新排序配置文件失败: {}", err);
|
||||
Err(format!("重新排序配置文件失败: {}", err).into())
|
||||
Err(coded_error("PROFILE_REORDER_FAILED", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +123,9 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
|
||||
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
||||
Ok(_) => {
|
||||
profiles_save_file_safe().await.stringify_err()?;
|
||||
profiles_save_file_safe()
|
||||
.await
|
||||
.with_error_code("PROFILE_CREATE_FAILED")?;
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
// 发送配置变更通知
|
||||
if let Some(uid) = &item.uid {
|
||||
@@ -134,10 +134,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => match err.to_string().as_str() {
|
||||
"the file already exists" => Err("the file already exists".into()),
|
||||
_ => Err(format!("add profile error: {err}").into()),
|
||||
},
|
||||
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +145,7 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "{}", e);
|
||||
Err(e.to_string().into())
|
||||
Err(coded_error("PROFILE_UPDATE_FAILED", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,8 +154,12 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile(index: String) -> CmdResult {
|
||||
// 使用Send-safe helper函数
|
||||
let should_update = profiles_delete_item_safe(&index).await.stringify_err()?;
|
||||
profiles_save_file_safe().await.stringify_err()?;
|
||||
let should_update = profiles_delete_item_safe(&index)
|
||||
.await
|
||||
.with_error_code("PROFILE_DELETE_FAILED")?;
|
||||
profiles_save_file_safe()
|
||||
.await
|
||||
.with_error_code("PROFILE_DELETE_FAILED")?;
|
||||
if let Err(e) = Tray::global().update_tooltip().await {
|
||||
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
|
||||
}
|
||||
@@ -177,15 +178,18 @@ pub async fn delete_profile(index: String) -> CmdResult {
|
||||
Ok(outcome) => {
|
||||
logging!(warn, Type::Cmd, "删除订阅后更新配置失败: {}", outcome);
|
||||
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
||||
return Err(outcome.to_string().into());
|
||||
return Err(coded_error("PROFILE_DELETE_FAILED", outcome));
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(error, Type::Cmd, "{}", e);
|
||||
return Err(e.to_string().into());
|
||||
return Err(coded_error("PROFILE_DELETE_FAILED", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer::global().refresh().await.stringify_err()?;
|
||||
Timer::global()
|
||||
.refresh()
|
||||
.await
|
||||
.with_error_code("PROFILE_DELETE_FAILED")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -276,12 +280,14 @@ async fn handle_update_error<E: std::fmt::Display>(
|
||||
Ok(ValidationOutcome::invalid_from_message(message))
|
||||
}
|
||||
|
||||
async fn handle_timeout(current_profile: Option<&String>) -> CmdResult<ValidationOutcome> {
|
||||
let timeout_msg: String = "配置更新超时(30秒),可能是配置验证或核心通信阻塞".into();
|
||||
logging!(error, Type::Cmd, "{}", timeout_msg);
|
||||
discard_and_restore(current_profile).await?;
|
||||
handle::Handle::notice_message("config_validate::timeout", timeout_msg.clone());
|
||||
Ok(ValidationOutcome::invalid_from_message(timeout_msg))
|
||||
async fn run_profile_config_update_transition<Update, UpdateFuture>(
|
||||
update_config: Update,
|
||||
) -> anyhow::Result<ValidationOutcome>
|
||||
where
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
|
||||
{
|
||||
update_config().await
|
||||
}
|
||||
|
||||
async fn perform_config_update(
|
||||
@@ -291,14 +297,12 @@ async fn perform_config_update(
|
||||
defer! {
|
||||
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
||||
}
|
||||
let update_result =
|
||||
tokio::time::timeout(Duration::from_secs(30), CoreManager::global().update_config_forced()).await;
|
||||
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
|
||||
|
||||
match update_result {
|
||||
Ok(Ok(outcome)) if outcome.is_valid() => handle_success(current_value).await,
|
||||
Ok(Ok(outcome)) => handle_validation_failure(outcome, current_profile).await,
|
||||
Ok(Err(e)) => handle_update_error(e, current_profile).await,
|
||||
Err(_) => handle_timeout(current_profile).await,
|
||||
Ok(outcome) if outcome.is_valid() => handle_success(current_value).await,
|
||||
Ok(outcome) => handle_validation_failure(outcome, current_profile).await,
|
||||
Err(e) => handle_update_error(e, current_profile).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +327,9 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
|
||||
|
||||
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
|
||||
|
||||
perform_config_update(target_profile, previous_profile.as_ref()).await
|
||||
perform_config_update(target_profile, previous_profile.as_ref())
|
||||
.await
|
||||
.map_err(|error| coded_error("PROFILE_SWITCH_FAILED", error))
|
||||
}
|
||||
|
||||
/// 根据profile name修改profiles
|
||||
@@ -355,7 +361,17 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
false
|
||||
};
|
||||
|
||||
profiles_patch_item_safe(&index, &profile).await.stringify_err()?;
|
||||
// A selection written from the UI or the chain proxy is newer than anything a restore still
|
||||
// in flight captured; without this it would be pushed back to the older node moments later.
|
||||
let records_a_selection = profile.selected.is_some();
|
||||
|
||||
profiles_patch_item_safe(&index, &profile)
|
||||
.await
|
||||
.with_error_code("PROFILE_UPDATE_FAILED")?;
|
||||
|
||||
if records_a_selection {
|
||||
profiles::supersede_selected_activation();
|
||||
}
|
||||
|
||||
// 如果更新间隔或允许自动更新变更,异步刷新定时器
|
||||
if should_refresh_timer {
|
||||
@@ -380,17 +396,22 @@ pub async fn view_profile(index: String) -> CmdResult {
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
let file = profiles_ref
|
||||
.get_item(&index)
|
||||
.stringify_err()?
|
||||
.with_error_code("PROFILE_OPEN_FAILED")?
|
||||
.file
|
||||
.as_ref()
|
||||
.ok_or("the file field is null")?;
|
||||
.ok_or_else(|| coded_error("PROFILE_OPEN_FAILED", "the file field is null"))?;
|
||||
|
||||
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
|
||||
let path = dirs::app_profiles_dir()
|
||||
.with_error_code("PROFILE_OPEN_FAILED")?
|
||||
.join(file.as_str());
|
||||
if !path.exists() {
|
||||
return CmdResult::Err(format!("file not found \"{}\"", path.display()).into());
|
||||
return CmdResult::Err(coded_error(
|
||||
"PROFILE_OPEN_FAILED",
|
||||
format!("file not found \"{}\"", path.display()),
|
||||
));
|
||||
}
|
||||
|
||||
help::open_file(path).stringify_err()
|
||||
help::open_file(path).with_error_code("PROFILE_OPEN_FAILED")
|
||||
}
|
||||
|
||||
/// 读取配置文件内容
|
||||
@@ -400,23 +421,32 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
PrfItem {
|
||||
file: profiles_ref.get_item(&index).stringify_err()?.file.to_owned(),
|
||||
file: profiles_ref
|
||||
.get_item(&index)
|
||||
.with_error_code("PROFILE_READ_FAILED")?
|
||||
.file
|
||||
.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(file) = item.file.as_ref() {
|
||||
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
|
||||
let path = dirs::app_profiles_dir()
|
||||
.with_error_code("PROFILE_READ_FAILED")?
|
||||
.join(file.as_str());
|
||||
match tokio::fs::try_exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(String::new()),
|
||||
Err(err) => {
|
||||
return Err(format!("failed to check profile file \"{}\": {err}", path.display()).into());
|
||||
return Err(coded_error(
|
||||
"PROFILE_READ_FAILED",
|
||||
format!("failed to check profile file \"{}\": {err}", path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data = item.read_file().await.stringify_err()?;
|
||||
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
@@ -430,9 +460,32 @@ pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::commit_current_profile;
|
||||
use super::{commit_current_profile, run_profile_config_update_transition};
|
||||
use crate::config::{IProfiles, PrfItem};
|
||||
use crate::core::validate::ValidationOutcome;
|
||||
use clash_verge_draft::Draft;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
struct CancellationProbe {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for CancellationProbe {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed.load(Ordering::Acquire) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(uid: &str) -> PrfItem {
|
||||
PrfItem {
|
||||
@@ -467,4 +520,42 @@ mod tests {
|
||||
assert!(committed.get_item("new").is_ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> {
|
||||
let update_started = Arc::new(Barrier::new(2));
|
||||
let release_update = Arc::new(Barrier::new(2));
|
||||
let update_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let update_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut update = Box::pin(run_profile_config_update_transition({
|
||||
let update_started = Arc::clone(&update_started);
|
||||
let release_update = Arc::clone(&release_update);
|
||||
let update_cancelled = Arc::clone(&update_cancelled);
|
||||
let update_completed = Arc::clone(&update_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: update_cancelled,
|
||||
completed: Arc::clone(&update_completed),
|
||||
};
|
||||
update_started.wait().await;
|
||||
release_update.wait().await;
|
||||
update_completed.store(true, Ordering::Release);
|
||||
Ok(ValidationOutcome::Valid)
|
||||
}
|
||||
}));
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
update_started.wait().await;
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
|
||||
release_update.wait().await;
|
||||
assert!(update.await?.is_valid());
|
||||
assert!(update_completed.load(Ordering::Acquire));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
use super::CmdResult;
|
||||
use crate::core::tray::Tray;
|
||||
use crate::process::AsyncHandler;
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
config::Config,
|
||||
core::{
|
||||
handle::Handle,
|
||||
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
tray::Tray,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
/// Record which node a group is on, in the current profile.
|
||||
///
|
||||
/// Takes the group and the node rather than the whole selection list, and merges them into the
|
||||
/// profile on this side. The frontend used to send the list it had rendered, which made two
|
||||
/// selections made before that list refreshed into one overwriting the other: both were built
|
||||
/// from the same stale array, and the later write dropped the earlier group's choice. Since a
|
||||
/// core start re-applies whatever the profile holds, the dropped one then came back on restart.
|
||||
///
|
||||
/// The tray already recorded this way. Now there is one way.
|
||||
#[tauri::command]
|
||||
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
|
||||
crate::config::profiles::record_selected_node(&group_name, &node)
|
||||
.await
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
config
|
||||
.and_then(|config| config.get("proxy-groups"))
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|group| group.get("name"))
|
||||
.filter_map(|name| name.as_str())
|
||||
.filter(|name| !name.is_empty() && *name != "GLOBAL")
|
||||
.filter(|name| seen.insert((*name).to_owned()))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||
let runtime = Config::runtime().await;
|
||||
let latest_runtime = runtime.latest_arc();
|
||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
||||
|
||||
let mihomo = Handle::mihomo();
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
||||
let proxies = proxies.stringify_err()?;
|
||||
|
||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order,
|
||||
proxies,
|
||||
providers: providers.ok(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 同步托盘和GUI的代理选择状态
|
||||
#[tauri::command]
|
||||
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
||||
@@ -50,3 +111,31 @@ async fn run_tray_sync_loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
use super::runtime_group_order;
|
||||
|
||||
#[test]
|
||||
fn runtime_order_keeps_first_non_empty_non_global_name() {
|
||||
let config: Value = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
proxy-groups:
|
||||
- name: Beta
|
||||
- name: ""
|
||||
- name: GLOBAL
|
||||
- name: " Alpha "
|
||||
- name: Beta
|
||||
"#,
|
||||
)
|
||||
.expect("parse runtime");
|
||||
|
||||
assert_eq!(
|
||||
runtime_group_order(config.as_mapping()),
|
||||
["Beta".to_owned(), " Alpha ".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,28 +12,6 @@ pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
|
||||
Ok(Config::runtime().await.latest_arc().config.clone())
|
||||
}
|
||||
|
||||
/// 获取运行时代理组顺序
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_proxy_group_order() -> CmdResult<Vec<String>> {
|
||||
let runtime = Config::runtime().await;
|
||||
let runtime = runtime.latest_arc();
|
||||
|
||||
Ok(runtime
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("proxy-groups"))
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.map(|groups| {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|group| group.get("name"))
|
||||
.filter_map(|name| name.as_str())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 获取运行时YAML配置
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_yaml() -> CmdResult<String> {
|
||||
|
||||
@@ -1,35 +1,51 @@
|
||||
use super::{CmdResult, StringifyErr as _};
|
||||
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
|
||||
use super::{CmdResult, WithErrorCode as _};
|
||||
use crate::core::{
|
||||
CoreManager,
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
};
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager.controlled_stop_core_inner().await.with_error_code(error_code)?;
|
||||
}
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
.map_err(|e| format!("{op_type} Service failed: {e}").into())
|
||||
.with_error_code(error_code)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "Install").await
|
||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await
|
||||
CoreManager::global()
|
||||
.uninstall_service_and_start_sidecar()
|
||||
.await
|
||||
.with_error_code("SERVICE_UNINSTALL_FAILED")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reinstall_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "Reinstall").await
|
||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn repair_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "Repair").await
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_service_available() -> CmdResult<bool> {
|
||||
service::is_service_available().await.stringify_err()?;
|
||||
Ok(true)
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.await
|
||||
.with_error_code("SERVICE_SIDECAR_FAILED")
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
use crate::core::runstate::{RUN_STATE, RunStateView};
|
||||
|
||||
use crate::core::{CoreManager, manager::RunningMode};
|
||||
|
||||
/// 获取当前内核运行模式
|
||||
/// The whole Run State in one call.
|
||||
///
|
||||
/// Replaces the separate running-mode and service-install-state commands: they were three
|
||||
/// caches on three refresh intervals describing one fact, which the frontend then had to keep
|
||||
/// coherent by hand.
|
||||
#[tauri::command]
|
||||
pub async fn get_running_mode() -> Result<Arc<RunningMode>, String> {
|
||||
Ok(CoreManager::global().get_running_mode())
|
||||
pub async fn get_runtime_state() -> Result<RunStateView, String> {
|
||||
Ok(RUN_STATE.settled().await.to_view())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::Config;
|
||||
use crate::constants::{network, tun as tun_const};
|
||||
use crate::utils::dirs::{ipc_path, path_to_str};
|
||||
use crate::utils::dirs::{path_to_str, sidecar_ipc_path};
|
||||
use crate::utils::{dirs, help};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
@@ -326,7 +326,7 @@ impl IClashTemp {
|
||||
|
||||
pub fn guard_external_controller_ipc() -> String {
|
||||
// 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致
|
||||
ipc_path()
|
||||
sidecar_ipc_path()
|
||||
.ok()
|
||||
.and_then(|path| path_to_str(&path).ok().map(|s| s.into()))
|
||||
.unwrap_or_else(|| {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{IClashTemp, IProfiles, IVerge};
|
||||
use crate::{
|
||||
config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
|
||||
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
||||
constants::{files, timing},
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::{self, Handle},
|
||||
service, tray,
|
||||
tray,
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
enhance,
|
||||
@@ -18,8 +18,11 @@ use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use smartstring::alias::String;
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -30,6 +33,8 @@ pub struct Config {
|
||||
runtime_config: Draft<IRuntime>,
|
||||
}
|
||||
|
||||
static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
impl Config {
|
||||
pub async fn global() -> &'static Self {
|
||||
static CONFIG: OnceCell<Config> = OnceCell::const_new();
|
||||
@@ -73,34 +78,59 @@ impl Config {
|
||||
let verge = Self::verge().await.latest_arc();
|
||||
clash_verge_i18n::sync_locale(verge.language.as_deref());
|
||||
|
||||
// init Tun mode
|
||||
let handle = Handle::app_handle();
|
||||
let is_admin = is_current_app_handle_admin(handle);
|
||||
let is_service_available = service::is_service_available().await.is_ok();
|
||||
if !is_admin && !is_service_available {
|
||||
let verge = Self::verge().await;
|
||||
verge.edit_draft(|d| {
|
||||
d.enable_tun_mode = Some(false);
|
||||
});
|
||||
verge.apply();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 分离数据获取和异步调用避免Send问题
|
||||
let verge_data = Self::verge().await.latest_arc();
|
||||
logging_error!(Type::Core, verge_data.save_file().await);
|
||||
}
|
||||
pub fn tun_suppressed_for_session() -> bool {
|
||||
TUN_SESSION_SUPPRESSED.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(crate) async fn suppress_tun_for_session() {
|
||||
TUN_SESSION_SUPPRESSED.store(true, Ordering::Release);
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_tun_for_session() {
|
||||
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn disable_tun_and_persist() -> Result<()> {
|
||||
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
|
||||
let verge = Self::verge().await;
|
||||
verge.edit_draft(|draft| {
|
||||
draft.enable_tun_mode = Some(false);
|
||||
});
|
||||
verge.apply();
|
||||
verge.data_arc().save_file().await?;
|
||||
Handle::refresh_verge();
|
||||
let _ = tray::Tray::global().update_menu().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn init_runtime_config() -> Result<()> {
|
||||
let validation_result = Self::generate_and_validate().await?;
|
||||
let fallback_applied = match Self::resolve_startup_mixed_port().await {
|
||||
Ok(applied) => applied,
|
||||
Err(error) => {
|
||||
Self::block_startup_core(&error);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let validation_result = if fallback_applied {
|
||||
None
|
||||
} else {
|
||||
Self::generate_and_validate().await?
|
||||
};
|
||||
|
||||
if let Some((msg_type, msg_content)) = validation_result {
|
||||
sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
handle::Handle::notice_message(msg_type, msg_content);
|
||||
}
|
||||
|
||||
Self::runtime().await.apply();
|
||||
|
||||
{
|
||||
let profiles = Self::profiles().await.data_arc();
|
||||
// Logging error internally
|
||||
@@ -113,13 +143,26 @@ impl Config {
|
||||
// Ensure "Merge" and "Script" profile items exist, adding them if missing.
|
||||
async fn ensure_default_profile_items() -> Result<()> {
|
||||
let profiles = Self::profiles().await;
|
||||
Self::ensure_default_profile_items_for(&profiles).await
|
||||
}
|
||||
|
||||
async fn ensure_default_profile_items_for(profiles: &Draft<IProfiles>) -> Result<()> {
|
||||
if profiles.latest_arc().get_items().is_none() {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Profile items 无法加载,跳过默认项初始化以保留现有配置文件"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if profiles.latest_arc().get_item("Merge").is_err() {
|
||||
let merge_item = &mut PrfItem::from_merge(Some("Merge".into()))?;
|
||||
profiles_append_item_safe(merge_item).await?;
|
||||
profiles_append_item_to_safe(profiles, merge_item).await?;
|
||||
}
|
||||
if profiles.latest_arc().get_item("Script").is_err() {
|
||||
let script_item = &mut PrfItem::from_script(Some("Script".into()))?;
|
||||
profiles_append_item_safe(script_item).await?;
|
||||
profiles_append_item_to_safe(profiles, script_item).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -217,6 +260,10 @@ impl Config {
|
||||
}
|
||||
|
||||
pub async fn verify_config_initialization() {
|
||||
if Self::startup_core_block_reason().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let backoff = ExponentialBuilder::default()
|
||||
.with_min_delay(std::time::Duration::from_millis(100))
|
||||
.with_max_delay(std::time::Duration::from_secs(2))
|
||||
@@ -369,4 +416,29 @@ mod tests {
|
||||
let box_iruntime_size = std::mem::size_of_val(&draft);
|
||||
assert_eq!(box_iruntime_size, std::mem::size_of::<Draft<Box<IRuntime>>>());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_profile_index_survives_startup_without_cleanup() -> Result<()> {
|
||||
let profiles = Draft::new(IProfiles::default());
|
||||
let profiles_dir = std::env::temp_dir().join(format!("clash-verge-profile-cleanup-{}", nanoid::nanoid!()));
|
||||
tokio::fs::create_dir_all(&profiles_dir).await?;
|
||||
let active_profile = profiles_dir.join("Ractive.yaml");
|
||||
tokio::fs::write(&active_profile, "proxies: []").await?;
|
||||
|
||||
Config::ensure_default_profile_items_for(&profiles).await?;
|
||||
profiles.data_arc().cleanup_orphaned_files_in(&profiles_dir).await?;
|
||||
|
||||
let profile_was_preserved = tokio::fs::try_exists(&active_profile).await?;
|
||||
tokio::fs::remove_dir_all(&profiles_dir).await?;
|
||||
|
||||
assert!(
|
||||
profile_was_preserved,
|
||||
"startup must not delete profiles when profiles.yaml could not be loaded"
|
||||
);
|
||||
assert!(
|
||||
profiles.data_arc().get_items().is_none(),
|
||||
"startup must not replace an unreadable profile index with defaults"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Resolving the Mixed Port.
|
||||
//!
|
||||
//! Two different questions were being asked with one answer:
|
||||
//!
|
||||
//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that
|
||||
//! writes configuration or decides what to start needs this, and it has to be answerable
|
||||
//! before the Core exists.
|
||||
//! - *What is the Core actually serving on?* — what the Core reports, else what we configured.
|
||||
//! Anything that points a client at the proxy wants this, because the two can differ: a
|
||||
//! startup fallback moves the port, and a hand-edited Core config can move it too.
|
||||
//!
|
||||
//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config
|
||||
//! entirely) and the frontend hand-copied a version of the second.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::Config;
|
||||
use crate::core::handle::Handle;
|
||||
|
||||
/// The port mihomo listens on when nothing else is configured.
|
||||
pub const DEFAULT_MIXED_PORT: u16 = 7897;
|
||||
|
||||
/// Resolving the Mixed Port.
|
||||
pub struct MixedPort;
|
||||
|
||||
impl MixedPort {
|
||||
/// The Mixed Port the app has configured.
|
||||
///
|
||||
/// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe
|
||||
/// to call before the Core is running, unlike [`Self::effective`].
|
||||
///
|
||||
/// Reading the draft means a caller that hands this to something *outside* the app — the
|
||||
/// PAC endpoint is the one that does — can name a port the Core has not moved to yet.
|
||||
/// What makes that safe is that the only path staging a listener port,
|
||||
/// `feat::listener::save_proxy_ports`, closes PAC across the staging with `core_starting`
|
||||
/// and reopens it with `core_start_settled`. A second path that stages a listener port
|
||||
/// while the Core is serving would have to do the same.
|
||||
pub async fn desired() -> u16 {
|
||||
let selected = Config::verge().await.latest_arc().verge_mixed_port;
|
||||
// `get_mixed_port` already falls back to the default when the Merge Config is silent.
|
||||
let merged = Config::clash().await.latest_arc().get_mixed_port();
|
||||
resolve_desired(selected, merged)
|
||||
}
|
||||
|
||||
/// The Mixed Port the Core is actually serving on.
|
||||
///
|
||||
/// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on
|
||||
/// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked,
|
||||
/// which is the right answer whenever the Core is not running anyway.
|
||||
pub async fn effective() -> u16 {
|
||||
let desired = Self::desired().await;
|
||||
resolve_effective(
|
||||
|| async { Ok(Handle::mihomo().get_base_config().await?.mixed_port) },
|
||||
desired,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer what the user selected, else what the Merge Config resolved to.
|
||||
const fn resolve_desired(selected: Option<u16>, merged: u16) -> u16 {
|
||||
match selected {
|
||||
Some(port) => port,
|
||||
None => merged,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer what the Core reports; fall back to what we configured.
|
||||
async fn resolve_effective<Live, LiveFuture>(live: Live, desired: u16) -> u16
|
||||
where
|
||||
Live: FnOnce() -> LiveFuture,
|
||||
LiveFuture: Future<Output = Result<u16>>,
|
||||
{
|
||||
match live().await {
|
||||
// A Core reporting port 0 is a Core that has not bound anything yet.
|
||||
Ok(port) if port != 0 => port,
|
||||
_ => desired,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_selected_port_wins_over_the_merge_config() {
|
||||
assert_eq!(resolve_desired(Some(9000), 7897), 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_config_answers_when_nothing_is_selected() {
|
||||
assert_eq!(resolve_desired(None, 8080), 8080);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reporting_core_overrides_what_we_configured() {
|
||||
// This is the startup-fallback case: we asked for 7897 and the Core landed on 7898.
|
||||
assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_core_leaves_the_configured_port_standing() {
|
||||
assert_eq!(
|
||||
resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await,
|
||||
7897
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_core_that_has_not_bound_yet_is_not_believed() {
|
||||
assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ mod clash;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod config;
|
||||
mod encrypt;
|
||||
mod mixed_port;
|
||||
mod port;
|
||||
mod prfitem;
|
||||
pub mod profiles;
|
||||
pub mod runtime;
|
||||
pub(crate) mod snapshot;
|
||||
mod verge;
|
||||
|
||||
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
|
||||
pub use self::{clash::*, config::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
|
||||
|
||||
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
|
||||
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use super::{
|
||||
Config, ConfigType, IClashTemp, IVerge,
|
||||
snapshot::{capture_config_files, restore_files},
|
||||
};
|
||||
use crate::{
|
||||
constants::timing,
|
||||
core::{
|
||||
handle::Handle,
|
||||
listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys},
|
||||
owner_identity::current_owner_credentials,
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
utils::port::find_next_available_port,
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use serde_yaml_ng::Value;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
net::SocketAddr,
|
||||
str::FromStr as _,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MixedPortFallback {
|
||||
original: u16,
|
||||
current: u16,
|
||||
}
|
||||
|
||||
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
|
||||
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
|
||||
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
impl Config {
|
||||
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
||||
Self::resolve_startup_mixed_port_inner().await
|
||||
}
|
||||
|
||||
pub(crate) async fn retry_startup_mixed_port_fallback() -> Result<bool> {
|
||||
Self::resolve_startup_mixed_port_inner().await
|
||||
}
|
||||
|
||||
async fn resolve_startup_mixed_port_inner() -> Result<bool> {
|
||||
let clash = Self::clash().await.latest_arc();
|
||||
let verge = Self::verge().await.latest_arc();
|
||||
let selected_port = clash.get_mixed_port();
|
||||
let bind_scope =
|
||||
ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?;
|
||||
|
||||
if owned_service_core_uses_port(selected_port).await {
|
||||
logging!(
|
||||
info,
|
||||
Type::Setup,
|
||||
"Mixed proxy port {} belongs to the current user's managed core; skipping fallback",
|
||||
selected_port
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let selected_scope = bind_scope.clone();
|
||||
let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port))
|
||||
.await
|
||||
.context("mixed proxy port probe task failed")?;
|
||||
if !port_in_use {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let reserved = configured_listener_ports(&clash, &verge);
|
||||
let candidate = AsyncHandler::spawn_blocking(move || {
|
||||
find_next_available_port(selected_port, &reserved, |port| {
|
||||
bind_scope.mixed_port_is_available(port)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.context("mixed proxy fallback scan task failed")?
|
||||
.ok_or_else(|| anyhow!("no eligible mixed proxy port is available"))?;
|
||||
|
||||
Self::apply_startup_mixed_port_fallback(selected_port, candidate).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn apply_startup_mixed_port_fallback(old_port: u16, new_port: u16) -> Result<()> {
|
||||
let clash = Self::clash().await;
|
||||
let verge = Self::verge().await;
|
||||
let runtime = Self::runtime().await;
|
||||
// Every failure below leaves the three layers as they were, without saying so.
|
||||
let transaction = DraftTransaction::begin(vec![&clash, &verge, &runtime])?;
|
||||
|
||||
clash.edit_draft(|draft| {
|
||||
draft.0.insert(MIXED_PORT_KEY.into(), new_port.into());
|
||||
});
|
||||
verge.edit_draft(|draft| {
|
||||
draft.verge_mixed_port = Some(new_port);
|
||||
});
|
||||
|
||||
Self::generate()
|
||||
.await
|
||||
.context("failed to materialize runtime configuration with fallback port")?;
|
||||
|
||||
let validation = CoreConfigValidator::global()
|
||||
.validate_config_outcome()
|
||||
.await
|
||||
.context("failed to validate runtime configuration with fallback port")?;
|
||||
if !validation.is_valid() {
|
||||
bail!("runtime configuration with fallback port is invalid: {validation}");
|
||||
}
|
||||
|
||||
let snapshots = capture_config_files().await?;
|
||||
let candidate_clash = clash.latest_arc();
|
||||
let candidate_verge = verge.latest_arc();
|
||||
|
||||
let persist_result = async {
|
||||
candidate_clash
|
||||
.save_config()
|
||||
.await
|
||||
.context("failed to persist Application Merge Configuration")?;
|
||||
candidate_verge
|
||||
.save_file()
|
||||
.await
|
||||
.context("failed to persist selected mixed proxy port")?;
|
||||
Self::generate_file(ConfigType::Run)
|
||||
.await
|
||||
.context("failed to persist Runtime Configuration")?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Files are not part of the transaction, so a failed write is restored explicitly.
|
||||
// The drafts are rolled back first: restoring reads the committed configuration, and
|
||||
// must not see a candidate that is being abandoned.
|
||||
if let Err(error) = persist_result {
|
||||
transaction.rollback();
|
||||
return match restore_files(&snapshots).await {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
|
||||
};
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
record_fallback(old_port, new_port);
|
||||
Handle::refresh_clash();
|
||||
Handle::refresh_verge();
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Mixed proxy port {} is externally occupied; switched to {} and persisted all configuration layers",
|
||||
old_port,
|
||||
new_port
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn block_startup_core(error: &anyhow::Error) {
|
||||
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
|
||||
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
|
||||
report_fallback_error(error.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn startup_core_block_reason() -> Option<String> {
|
||||
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
|
||||
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn notify_startup_mixed_port_fallback() {
|
||||
let Some(change) = PENDING_FALLBACK_NOTICE.lock().take() else {
|
||||
return;
|
||||
};
|
||||
AsyncHandler::spawn(move || async move {
|
||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
Handle::notice_message(
|
||||
"mixed_port::fallback",
|
||||
format!("{},{}", change.original, change.current),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn record_fallback(old_port: u16, new_port: u16) {
|
||||
let mut pending = PENDING_FALLBACK_NOTICE.lock();
|
||||
match pending.as_mut() {
|
||||
Some(change) => change.current = new_port,
|
||||
None => {
|
||||
*pending = Some(MixedPortFallback {
|
||||
original: old_port,
|
||||
current: new_port,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_fallback_error(message: String) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Config,
|
||||
"Automatic mixed proxy port fallback failed: {}",
|
||||
message
|
||||
);
|
||||
AsyncHandler::spawn(move || async move {
|
||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
Handle::notice_message("mixed_port::fallback_error", message);
|
||||
});
|
||||
}
|
||||
|
||||
// Only service-managed cores can survive into this startup phase; this app has not spawned its sidecar yet.
|
||||
async fn owned_service_core_uses_port(port: u16) -> bool {
|
||||
if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let credentials = match current_owner_credentials() {
|
||||
Ok(credentials) => credentials,
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"Unable to identify current service owner while checking mixed proxy port: {error:#}"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let response = match clash_verge_service_ipc::get_status(&credentials).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"Unable to query current service owner while checking mixed proxy port: {error:#}"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Some(status) = response.data else {
|
||||
return false;
|
||||
};
|
||||
if response.code > 0 || !status.is_active || status.core_pid.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match Handle::mihomo().get_base_config().await {
|
||||
Ok(config) => config.mixed_port == port,
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"Current user's service core is active but its mixed proxy port is unavailable: {error}; \
|
||||
preserving the selected port until core replacement resolves ownership"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet<u16> {
|
||||
let mut ports = HashSet::new();
|
||||
// Every listener except the Mixed Port itself: that is the one being reassigned, so
|
||||
// colliding with its current value is exactly what we are trying to do.
|
||||
for key in proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY) {
|
||||
if let Some(port) = mapping_port(&clash.0, key) {
|
||||
ports.insert(port);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(controller) = SocketAddr::from_str(IClashTemp::guard_external_controller(&clash.0).as_str()) {
|
||||
ports.insert(controller.port());
|
||||
}
|
||||
|
||||
ports.extend([verge.verge_socks_port, verge.verge_port].into_iter().flatten());
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(port) = verge.verge_redir_port {
|
||||
ports.insert(port);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(port) = verge.verge_tproxy_port {
|
||||
ports.insert(port);
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option<u16> {
|
||||
mapping.get(key).and_then(|value| match value {
|
||||
Value::String(port) => port.parse().ok(),
|
||||
Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod listener_key_tests {
|
||||
use super::{MIXED_PORT_KEY, proxy_listener_keys};
|
||||
|
||||
#[test]
|
||||
fn the_reserved_set_covers_every_listener_but_the_one_being_moved() {
|
||||
let reserved: Vec<_> = proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY).collect();
|
||||
|
||||
assert!(
|
||||
!reserved.contains(&MIXED_PORT_KEY),
|
||||
"the port being reassigned must not reserve itself"
|
||||
);
|
||||
assert_eq!(
|
||||
reserved.len(),
|
||||
proxy_listener_keys().count() - 1,
|
||||
"every other listener must be reserved, so a new one cannot be forgotten here"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::configured_listener_ports;
|
||||
use crate::config::{IClashTemp, IVerge};
|
||||
|
||||
#[test]
|
||||
fn configured_ports_include_disabled_listener_assignments() {
|
||||
let ports = configured_listener_ports(&IClashTemp::template(), &IVerge::template());
|
||||
assert!(ports.contains(&7898));
|
||||
assert!(ports.contains(&7899));
|
||||
assert!(ports.contains(&9097));
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert!(ports.contains(&7895));
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(ports.contains(&7896));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -22,7 +23,7 @@ use std::{
|
||||
LazyLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_mihomo::models::{Proxies, ProxyType};
|
||||
use tokio::{fs, task::JoinHandle};
|
||||
@@ -47,6 +48,21 @@ static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
// lock acquisition, connection-pool waiting, and local-socket connection establishment.
|
||||
const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1);
|
||||
/// How long a restore keeps trying to put back selections the core has not loaded yet.
|
||||
///
|
||||
/// A provider-backed group is present but empty until its provider finishes loading, which on a
|
||||
/// cold start is exactly when restoring runs. Bounded rather than indefinite because a record
|
||||
/// naming a node the profile genuinely no longer has looks identical from here, and would
|
||||
/// otherwise be retried for the life of the process.
|
||||
const SELECTED_NODES_SETTLE_DEADLINE: Duration = Duration::from_secs(30);
|
||||
/// How often a restore looks again while waiting for those groups.
|
||||
const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// How long a start waits for the selections that *can* be put back before carrying on.
|
||||
///
|
||||
/// Covers a healthy core answering one query and a handful of selects. Past that the start
|
||||
/// proceeds: the remaining groups need the core to finish loading, which is not something a
|
||||
/// start can usefully wait for.
|
||||
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Define the `profiles.yaml` schema
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -419,11 +435,27 @@ impl IProfiles {
|
||||
/// 以 app 中的 profile 列表为准,删除不再需要的文件
|
||||
pub async fn cleanup_orphaned_files(&self) -> Result<()> {
|
||||
let profiles_dir = dirs::app_profiles_dir()?;
|
||||
self.cleanup_orphaned_files_in(&profiles_dir).await
|
||||
}
|
||||
|
||||
pub(super) async fn cleanup_orphaned_files_in(&self, profiles_dir: &Path) -> Result<()> {
|
||||
if !profiles_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 如果 items 为空(例如 profiles.yaml 解析失败导致
|
||||
// IProfiles::new() 返回 default),此时无法判断哪些文件是活跃的,
|
||||
// 跳过清理以避免误删用户正在使用的订阅配置文件。
|
||||
// See: https://github.com/clash-verge-rev/clash-verge-rev/issues/7577
|
||||
if self.items.as_ref().is_none_or(|v| v.is_empty()) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Profile items 为空,跳过孤儿文件清理以避免误删活跃的配置文件"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 获取所有 active profile 的文件名集合
|
||||
let active_files = self.get_all_active_files();
|
||||
|
||||
@@ -571,8 +603,12 @@ pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data:
|
||||
}
|
||||
|
||||
pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
|
||||
Config::profiles()
|
||||
.await
|
||||
let profiles = Config::profiles().await;
|
||||
profiles_append_item_to_safe(&profiles, item).await
|
||||
}
|
||||
|
||||
pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, item: &mut PrfItem) -> Result<()> {
|
||||
profiles
|
||||
.with_data_modify(|mut profiles| async move {
|
||||
profiles.append_item(item).await?;
|
||||
Ok((profiles, ()))
|
||||
@@ -751,6 +787,21 @@ fn reconcile_selected_nodes(
|
||||
plan
|
||||
}
|
||||
|
||||
/// Abandon any activation still in flight.
|
||||
///
|
||||
/// An activation reads the profile, then polls the core until its groups are readable, then puts
|
||||
/// its selections. A choice the user makes inside that window is newer than what the activation
|
||||
/// captured, so letting it finish would push the core back to the older node — and the profile,
|
||||
/// already holding the newer one, would then disagree with the core. Bumping the generation is
|
||||
/// how an activation is told it has been overtaken.
|
||||
///
|
||||
/// Called *after* the profile is written, which is the only placement that needs to exist: an
|
||||
/// activation that read the older profile is cancelled by this, and one starting afterwards reads
|
||||
/// the newer one and needs no cancelling.
|
||||
pub fn supersede_selected_activation() {
|
||||
ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn is_activation_current(generation: u64) -> bool {
|
||||
ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) == generation
|
||||
}
|
||||
@@ -758,7 +809,7 @@ fn is_activation_current(generation: u64) -> bool {
|
||||
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
||||
loop {
|
||||
match handle::Handle::mihomo().await.get_proxies().await {
|
||||
match handle::Handle::mihomo().get_proxies().await {
|
||||
Ok(proxies) => return proxies,
|
||||
Err(err) => {
|
||||
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err}");
|
||||
@@ -773,10 +824,7 @@ async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||
|
||||
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
||||
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
||||
handle::Handle::mihomo()
|
||||
.await
|
||||
.select_node_for_group(group_name, node)
|
||||
.await
|
||||
handle::Handle::mihomo().select_node_for_group(group_name, node).await
|
||||
})
|
||||
.await
|
||||
.with_context(|| format!("timed out while selecting node [{node}] for group [{group_name}]"))?
|
||||
@@ -850,6 +898,59 @@ async fn update_tray_after_activation(generation: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record which node a group is on, in the current profile.
|
||||
///
|
||||
/// The counterpart of the frontend's `useRecordSelection`, for the selections the backend makes
|
||||
/// on the user's behalf — the tray is the one that does. What the profile holds is what
|
||||
/// [`activate_selected_nodes`] re-applies when a core starts, so a selection it never learned
|
||||
/// about is one the next start silently undoes.
|
||||
pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
||||
let group_name = String::from(group_name);
|
||||
let node = String::from(node);
|
||||
let recorded = Config::profiles()
|
||||
.await
|
||||
.with_data_modify(move |mut profiles| async move {
|
||||
let Some(current) = profiles.current.clone() else {
|
||||
return Ok((profiles, false));
|
||||
};
|
||||
let Some(item) = profiles
|
||||
.items
|
||||
.as_mut()
|
||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||
else {
|
||||
return Ok((profiles, false));
|
||||
};
|
||||
|
||||
let mut selected = item.selected.clone().unwrap_or_default();
|
||||
match selected
|
||||
.iter_mut()
|
||||
.find(|entry| entry.name.as_ref() == Some(&group_name))
|
||||
{
|
||||
Some(entry) => {
|
||||
if entry.now.as_ref() == Some(&node) {
|
||||
return Ok((profiles, false));
|
||||
}
|
||||
entry.now = Some(node);
|
||||
}
|
||||
None => selected.push(PrfSelected {
|
||||
name: Some(group_name),
|
||||
now: Some(node),
|
||||
}),
|
||||
}
|
||||
item.selected = Some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await?;
|
||||
|
||||
if recorded {
|
||||
// Newer than anything an activation still in flight captured.
|
||||
supersede_selected_activation();
|
||||
handle::Handle::refresh_profiles();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_reconciled_selected(
|
||||
profile_uid: &String,
|
||||
original_selected: &[PrfSelected],
|
||||
@@ -890,10 +991,104 @@ async fn persist_reconciled_selected(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The recorded selections the core is not currently on.
|
||||
///
|
||||
/// Deliberately "is the core on it" rather than "did we send a select": a group whose provider
|
||||
/// has not loaded is present but empty, a `select` can fail while the core is still settling, and
|
||||
/// a group that was already correct never produces an activation at all. Only the running state
|
||||
/// tells those apart.
|
||||
fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<String> {
|
||||
selected
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let (Some(group_name), Some(node)) = (&item.name, &item.now) else {
|
||||
return None;
|
||||
};
|
||||
match proxies.proxies.get(group_name.as_str()) {
|
||||
Some(group) if group.now.as_deref() == Some(node.as_str()) => None,
|
||||
_ => Some(group_name.clone()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep putting back the selections the core could not be moved to yet.
|
||||
///
|
||||
/// The single re-check a profile switch needs is enough there, because a switch happens once the
|
||||
/// configuration is loaded. A core start is the opposite case: the groups a restore is trying to
|
||||
/// put back may not exist yet. Without this the restore reported success having applied nothing,
|
||||
/// and the group stayed on the first entry of its `proxies:` list — the outcome restoring exists
|
||||
/// to prevent.
|
||||
///
|
||||
/// Only ever reached with [`SelectionRepair::KeepRecords`]: a profile switch is entitled to
|
||||
/// conclude that a group it cannot see is gone, so it has nothing to wait for.
|
||||
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
|
||||
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
|
||||
loop {
|
||||
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
|
||||
if !is_activation_current(generation) {
|
||||
return;
|
||||
}
|
||||
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
|
||||
// Unreachable core: the deadline still applies, so this cannot spin forever.
|
||||
if Instant::now() >= deadline {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !is_activation_current(generation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pending = unsettled_selections(selected, &snapshot);
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"gave up putting back {} selected node(s) the core never loaded: {}",
|
||||
pending.len(),
|
||||
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let plan = reconcile_selected_nodes(selected, None, &snapshot);
|
||||
if apply_activations(&plan.activations, completed, generation)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires once the selections that could be applied have been.
|
||||
///
|
||||
/// Dropping it without firing releases the waiter too: every way the worker can end early is a
|
||||
/// way of saying "nothing more is coming", and a start that waits forever for one of them would
|
||||
/// be a worse failure than the one this exists to prevent.
|
||||
struct FirstPassSignal(Option<tokio::sync::oneshot::Sender<()>>);
|
||||
|
||||
impl FirstPassSignal {
|
||||
fn notify(&mut self) {
|
||||
if let Some(sender) = self.0.take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn activate_selected_nodes_worker(
|
||||
profile_uid: String,
|
||||
selected: Vec<PrfSelected>,
|
||||
generation: u64,
|
||||
repair: SelectionRepair,
|
||||
mut first_pass_done: FirstPassSignal,
|
||||
) -> Result<()> {
|
||||
let first_snapshot = fetch_proxies_with_timeout().await?;
|
||||
if !is_activation_current(generation) {
|
||||
@@ -951,6 +1146,14 @@ async fn activate_selected_nodes_worker(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if repair == SelectionRepair::KeepRecords {
|
||||
// Everything the core could be moved to has been. Whoever is waiting to point traffic
|
||||
// here may go; what is left needs the core to finish loading and cannot be waited on.
|
||||
first_pass_done.notify();
|
||||
settle_pending_selections(&selected, &mut completed_activations, generation).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if plan.repaired_count > 0 && is_activation_current(generation) {
|
||||
logging!(
|
||||
info,
|
||||
@@ -964,13 +1167,64 @@ async fn activate_selected_nodes_worker(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an activation may also prune the records it cannot match.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum SelectionRepair {
|
||||
/// Drop records whose group or node the core does not have. Only safe once the configuration
|
||||
/// they belong to is known to be fully loaded, which is true right after a profile switch.
|
||||
Prune,
|
||||
/// Apply what can be applied and leave the records alone.
|
||||
///
|
||||
/// What a core start needs. A provider-backed group can be empty or absent for seconds after
|
||||
/// the core comes up, and a record pruned on that evidence is gone from `profiles.yaml` for
|
||||
/// good — the provider finishing later cannot bring it back.
|
||||
KeepRecords,
|
||||
}
|
||||
|
||||
pub fn activate_selected_nodes() -> Result<()> {
|
||||
// The first-pass signal is for callers that wait; a profile switch does not, and dropping
|
||||
// the receiver simply makes the send a no-op.
|
||||
drop(activate_selected_nodes_with(SelectionRepair::Prune));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put the profile's selections back before anything is pointed at the core.
|
||||
///
|
||||
/// Awaited, unlike a profile switch's activation, because the caller enables the system proxy as
|
||||
/// soon as this returns: a proxy pointed at a core still sitting on the first entry of every
|
||||
/// group is the outcome restoring exists to prevent. Bounded so that a core which will not answer
|
||||
/// delays a start rather than blocking it — the selections keep being retried either way, and
|
||||
/// being proxied through the wrong node beats not being proxied at all.
|
||||
///
|
||||
/// TUN is deliberately not covered, because it cannot be: it is configured in the core's own
|
||||
/// file, so it is carrying traffic from the moment the process starts, before anything here could
|
||||
/// run. What puts a TUN user on the right node from the first packet is the core's own
|
||||
/// `cache.db`, which is why the service keeps an owner's runtime generation across restarts.
|
||||
pub async fn restore_selected_nodes() {
|
||||
let first_pass = activate_selected_nodes_with(SelectionRepair::KeepRecords);
|
||||
if tokio::time::timeout(SELECTED_NODES_FIRST_PASS_BUDGET, first_pass)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"starting without having put the selected nodes back yet; still trying"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot::Receiver<()> {
|
||||
logging!(info, Type::Config, "starting activating selected nodes");
|
||||
let mut active_task = ACTIVATE_SELECTED_TASK.lock();
|
||||
let generation = ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let previous_task = active_task.take();
|
||||
let (first_pass_sender, first_pass_done) = tokio::sync::oneshot::channel();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
// Held for the whole task so that every exit — superseded, no profile, an error — drops
|
||||
// it and releases anyone waiting. Only the worker fires it deliberately.
|
||||
let first_pass = FirstPassSignal(Some(first_pass_sender));
|
||||
if let Some(previous_task) = previous_task {
|
||||
let _ = previous_task.await;
|
||||
}
|
||||
@@ -979,7 +1233,12 @@ pub fn activate_selected_nodes() -> Result<()> {
|
||||
}
|
||||
|
||||
let result = async {
|
||||
let profiles = Config::profiles().await.latest_arc();
|
||||
// Committed, not the draft. A profile switch stages its target before validating
|
||||
// it, and a switch that then fails discards that draft — but an activation which had
|
||||
// already read it would go on to apply the rejected profile's selections to the one
|
||||
// still running. The switch that succeeds commits before it activates, so this is the
|
||||
// same value there.
|
||||
let profiles = Config::profiles().await.data_arc();
|
||||
let current = profiles.get_current().context("no current profile running")?.clone();
|
||||
let selected = profiles
|
||||
.get_item(¤t)
|
||||
@@ -994,7 +1253,7 @@ pub fn activate_selected_nodes() -> Result<()> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
activate_selected_nodes_worker(current, selected, generation).await
|
||||
activate_selected_nodes_worker(current, selected, generation, repair, first_pass).await
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -1010,7 +1269,7 @@ pub fn activate_selected_nodes() -> Result<()> {
|
||||
});
|
||||
*active_task = Some(handle);
|
||||
drop(active_task);
|
||||
Ok(())
|
||||
first_pass_done
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1045,6 +1304,115 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_the_core_has_not_loaded_is_still_unsettled() {
|
||||
// The regression this pins. A provider-backed group is present but empty until its
|
||||
// provider loads, which on a cold start is exactly when restoring runs. Reconciling
|
||||
// produces no activation for it, so a restore that stopped after one re-check reported
|
||||
// success having left the group on the first entry of its `proxies:` list.
|
||||
let saved = vec![selected("provider-group", "saved")];
|
||||
let unloaded = proxies(vec![("provider-group", &[], None)]);
|
||||
|
||||
assert!(
|
||||
reconcile_selected_nodes(&saved, Some(&unloaded), &unloaded)
|
||||
.activations
|
||||
.is_empty(),
|
||||
"nothing can be activated while the group is empty"
|
||||
);
|
||||
assert_eq!(
|
||||
unsettled_selections(&saved, &unloaded),
|
||||
vec![String::from("provider-group")],
|
||||
"so the restore must keep looking rather than call it done"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_the_core_is_already_on_is_settled() {
|
||||
let saved = vec![selected("Proxy", "Node A")];
|
||||
let loaded = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node A"))]);
|
||||
|
||||
assert!(
|
||||
unsettled_selections(&saved, &loaded).is_empty(),
|
||||
"a group already on its node needs nothing, even though it produces no activation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_on_the_wrong_node_is_unsettled() {
|
||||
// Covers a `select` that failed transiently: the group is loaded, the node exists, and
|
||||
// the core is simply not on it. Retrying is the only thing that fixes that.
|
||||
let saved = vec![selected("Proxy", "Node A")];
|
||||
let wrong = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node B"))]);
|
||||
|
||||
assert_eq!(unsettled_selections(&saved, &wrong), vec![String::from("Proxy")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_without_a_group_or_node_is_not_waited_on() {
|
||||
// A malformed record cannot be satisfied by waiting, and holding the settle loop open
|
||||
// for it would delay giving up on everything else.
|
||||
let malformed = vec![
|
||||
PrfSelected {
|
||||
name: Some("Proxy".into()),
|
||||
now: None,
|
||||
},
|
||||
PrfSelected {
|
||||
name: None,
|
||||
now: Some("Node A".into()),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(unsettled_selections(&malformed, &proxies(vec![])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_missing_from_both_snapshots_is_dropped_from_the_records() {
|
||||
// This is the pruning a profile switch wants and a core start must not do: the record is
|
||||
// gone from the plan, and `persist_reconciled_selected` writes the plan back. Pinned here
|
||||
// because it is why `SelectionRepair` exists — the predicate is right, the question is
|
||||
// only who is entitled to act on it.
|
||||
let saved = vec![selected("provider-group", "saved")];
|
||||
let empty = proxies(vec![]);
|
||||
|
||||
let plan = reconcile_selected_nodes(&saved, Some(&empty), &empty);
|
||||
|
||||
assert!(
|
||||
plan.selected.is_empty(),
|
||||
"a group absent from both looks invalid, so the record is dropped"
|
||||
);
|
||||
assert_eq!(plan.repaired_count, 1, "and dropping it counts as a repair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_missing_from_only_the_second_snapshot_is_kept() {
|
||||
// Absent once is not evidence: only a group that was already absent when the first
|
||||
// snapshot was taken is treated as gone.
|
||||
let saved = vec![selected("provider-group", "saved")];
|
||||
let had_it = proxies(vec![("provider-group", &["saved"], Some("saved"))]);
|
||||
let lost_it = proxies(vec![]);
|
||||
|
||||
let plan = reconcile_selected_nodes(&saved, Some(&had_it), &lost_it);
|
||||
|
||||
assert_eq!(plan.selected, saved);
|
||||
assert_eq!(plan.repaired_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseding_an_activation_makes_the_one_in_flight_stand_down() {
|
||||
// What a selection made during a start relies on: the restore that is still polling the
|
||||
// core has to notice it has been overtaken, or it will push the older node back.
|
||||
let generation = ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) + 1;
|
||||
ACTIVATE_SELECTED_GENERATION.store(generation, Ordering::Release);
|
||||
assert!(is_activation_current(generation));
|
||||
|
||||
supersede_selected_activation();
|
||||
|
||||
assert!(
|
||||
!is_activation_current(generation),
|
||||
"an activation that has been overtaken must stop before it puts anything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_valid_selection_and_activates_when_needed() {
|
||||
let saved = vec![selected("group", "saved")];
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::utils::dirs;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use std::{io::ErrorKind, path::PathBuf};
|
||||
|
||||
pub(crate) struct FileSnapshot {
|
||||
path: PathBuf,
|
||||
content: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
async fn capture_files(paths: &[PathBuf]) -> Result<Vec<FileSnapshot>> {
|
||||
let mut snapshots = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let content = match tokio::fs::read(path).await {
|
||||
Ok(content) => Some(content),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => None,
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| format!("failed to snapshot {}", path.display()));
|
||||
}
|
||||
};
|
||||
snapshots.push(FileSnapshot {
|
||||
path: path.clone(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
pub(crate) async fn capture_config_files() -> Result<Vec<FileSnapshot>> {
|
||||
let runtime_path = dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG);
|
||||
capture_files(&[dirs::clash_path()?, dirs::verge_path()?, runtime_path]).await
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_files(snapshots: &[FileSnapshot]) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for snapshot in snapshots {
|
||||
let result = match &snapshot.content {
|
||||
Some(content) => tokio::fs::write(&snapshot.path, content).await,
|
||||
None => match tokio::fs::remove_file(&snapshot.path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
};
|
||||
if let Err(error) = result {
|
||||
errors.push(format!("{}: {}", snapshot.path.display(), error));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("{}", errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_files, restore_files};
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_every_configuration_layer_after_partial_write() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"config-rollback-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_nanos()
|
||||
));
|
||||
tokio::fs::create_dir_all(&root).await?;
|
||||
let paths = [
|
||||
root.join("config.yaml"),
|
||||
root.join("verge.yaml"),
|
||||
root.join("clash-verge.yaml"),
|
||||
];
|
||||
for (index, path) in paths.iter().enumerate() {
|
||||
tokio::fs::write(path, format!("original-{index}")).await?;
|
||||
}
|
||||
|
||||
let snapshots = capture_files(&paths).await?;
|
||||
tokio::fs::write(&paths[0], "changed").await?;
|
||||
tokio::fs::write(&paths[1], "changed").await?;
|
||||
tokio::fs::remove_file(&paths[2]).await?;
|
||||
restore_files(&snapshots).await?;
|
||||
|
||||
for (index, path) in paths.iter().enumerate() {
|
||||
assert_eq!(tokio::fs::read_to_string(path).await?, format!("original-{index}"));
|
||||
}
|
||||
tokio::fs::remove_dir_all(root).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -556,10 +556,6 @@ impl IVerge {
|
||||
patch!(enable_external_controller);
|
||||
}
|
||||
|
||||
pub const fn get_singleton_port() -> u16 {
|
||||
crate::constants::network::ports::SINGLETON_SERVER
|
||||
}
|
||||
|
||||
/// 获取日志等级
|
||||
pub fn get_log_level(&self) -> LevelFilter {
|
||||
if let Some(level) = self.app_log_level.as_ref() {
|
||||
|
||||
@@ -11,11 +11,6 @@ pub mod network {
|
||||
pub const DEFAULT_MIXED: u16 = 7897;
|
||||
pub const DEFAULT_SOCKS: u16 = 7898;
|
||||
pub const DEFAULT_HTTP: u16 = 7899;
|
||||
|
||||
#[cfg(not(feature = "verge-dev"))]
|
||||
pub const SINGLETON_SERVER: u16 = 33331;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
pub const SINGLETON_SERVER: u16 = 11233;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +20,10 @@ pub mod timing {
|
||||
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
// How long a re-asked staging request is worth waiting for. One that already landed answers in
|
||||
// milliseconds; anything slower means the Service is still working, so ReplaceCore beats waiting.
|
||||
pub const STAGE_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
// Windows 服务冷启动较慢,避免过早回退 sidecar。
|
||||
#[cfg(target_os = "windows")]
|
||||
pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(30000);
|
||||
|
||||
@@ -265,7 +265,7 @@ impl WebDavClient {
|
||||
fn parse_webdav_list(xml: &str) -> Result<Vec<ListEntity>, reqwest_dav::Error> {
|
||||
// Some WebDAV servers emit RFC 2822's numeric UTC offset instead of HTTP-date's `GMT`.
|
||||
let normalized_xml = xml.replace(" +0000</", " GMT</");
|
||||
let multi_status: ListMultiStatus = serde_xml_rs::from_str(&normalized_xml)?;
|
||||
let multi_status: ListMultiStatus = reqwest_dav::re_exports::serde_xml_rs::from_str(&normalized_xml)?;
|
||||
multi_status.responses.into_iter().map(ListEntity::try_from).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||
use tokio::sync::RwLockReadGuard;
|
||||
|
||||
use super::notification::{FrontendEvent, NotificationSystem};
|
||||
|
||||
@@ -32,8 +31,8 @@ impl Handle {
|
||||
APP_HANDLE.get().expect("App handle not initialized")
|
||||
}
|
||||
|
||||
pub async fn mihomo() -> RwLockReadGuard<'static, Mihomo> {
|
||||
Self::app_handle().mihomo().read().await
|
||||
pub fn mihomo() -> &'static Mihomo {
|
||||
Self::app_handle().mihomo()
|
||||
}
|
||||
|
||||
pub fn refresh_clash() {
|
||||
@@ -48,6 +47,17 @@ impl Handle {
|
||||
Self::send_event(FrontendEvent::RefreshProfiles);
|
||||
}
|
||||
|
||||
/// Push a Run State snapshot to the frontend.
|
||||
///
|
||||
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
|
||||
/// stopped or that the Service came back.
|
||||
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
|
||||
let Ok(state) = serde_json::to_value(state) else {
|
||||
return;
|
||||
};
|
||||
Self::send_event(FrontendEvent::RunStateChanged { state });
|
||||
}
|
||||
|
||||
pub fn notify_profile_changed(profile_id: &String) {
|
||||
Self::send_event(FrontendEvent::ProfileChanged {
|
||||
current_profile_id: profile_id,
|
||||
@@ -80,6 +90,10 @@ impl Handle {
|
||||
self.is_exiting.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn clear_is_exiting(&self) {
|
||||
self.is_exiting.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn is_exiting(&self) -> bool {
|
||||
self.is_exiting.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
|
||||
use std::{
|
||||
io,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
str::FromStr as _,
|
||||
};
|
||||
|
||||
/// The config key of the Mixed Port, named once so callers stop spelling it out.
|
||||
pub(crate) const MIXED_PORT_KEY: &str = "mixed-port";
|
||||
|
||||
const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [
|
||||
(
|
||||
MIXED_PORT_KEY,
|
||||
"mixed",
|
||||
&[ListenerTransport::Tcp, ListenerTransport::Udp],
|
||||
),
|
||||
("socks-port", "socks", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
|
||||
("port", "http", &[ListenerTransport::Tcp]),
|
||||
("redir-port", "redir", &[ListenerTransport::Tcp]),
|
||||
(
|
||||
"tproxy-port",
|
||||
"tproxy",
|
||||
&[ListenerTransport::Tcp, ListenerTransport::Udp],
|
||||
),
|
||||
];
|
||||
|
||||
/// The config key of every proxy listener.
|
||||
///
|
||||
/// Callers that need "every listener except one" filter this rather than keeping their own
|
||||
/// list, so adding a listener cannot be half-applied.
|
||||
pub(crate) fn proxy_listener_keys() -> impl Iterator<Item = &'static str> {
|
||||
PROXY_LISTENERS.iter().map(|(key, _, _)| *key)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyPortSettings {
|
||||
pub mixed_port: u16,
|
||||
pub socks: ToggleableProxyPort,
|
||||
pub http: ToggleableProxyPort,
|
||||
pub redir: ToggleableProxyPort,
|
||||
pub tproxy: ToggleableProxyPort,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
pub struct ToggleableProxyPort {
|
||||
pub enabled: bool,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListenerProbe {
|
||||
pub address: String,
|
||||
pub transports: Vec<ListenerTransport>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ListenerTransport {
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ListenerProbeOutcome {
|
||||
Available,
|
||||
Conflict { port: u16, transport: ListenerTransport },
|
||||
Invalid { message: String },
|
||||
Indeterminate { message: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum SaveProxyPortsOutcome {
|
||||
Saved,
|
||||
Conflict { port: u16, transport: ListenerTransport },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ListenerBindScope {
|
||||
addresses: Vec<IpAddr>,
|
||||
}
|
||||
|
||||
impl ListenerBindScope {
|
||||
pub(crate) fn from_mapping(config: &Mapping) -> Result<Self> {
|
||||
Ok(Self {
|
||||
addresses: proxy_bind_addresses(config)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
|
||||
let claims = self
|
||||
.addresses
|
||||
.iter()
|
||||
.copied()
|
||||
.flat_map(|address| {
|
||||
[ListenerTransport::Tcp, ListenerTransport::Udp]
|
||||
.into_iter()
|
||||
.map(move |transport| BindClaim::new("mixed", address, port, transport))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
matches!(probe_claims(&claims), ListenerProbeOutcome::Available)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyPortSettings {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
if self.mixed_port == 0 {
|
||||
bail!("mixed proxy port must be between 1 and 65535");
|
||||
}
|
||||
for (name, listener) in [
|
||||
("SOCKS", self.socks),
|
||||
("HTTP", self.http),
|
||||
("redir", self.redir),
|
||||
("TProxy", self.tproxy),
|
||||
] {
|
||||
if listener.port == 0 {
|
||||
bail!("{name} proxy port must be between 1 and 65535");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct BindClaim {
|
||||
name: &'static str,
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
transport: ListenerTransport,
|
||||
}
|
||||
|
||||
impl BindClaim {
|
||||
const fn new(name: &'static str, address: IpAddr, port: u16, transport: ListenerTransport) -> Self {
|
||||
Self {
|
||||
name,
|
||||
address,
|
||||
port,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
const fn socket_addr(&self) -> SocketAddr {
|
||||
SocketAddr::new(self.address, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn probe_listener(request: &ListenerProbe) -> ListenerProbeOutcome {
|
||||
let (addresses, port) = match parse_listener_address(&request.address) {
|
||||
Ok(address) => address,
|
||||
Err(error) => {
|
||||
return ListenerProbeOutcome::Invalid {
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut transports = Vec::with_capacity(request.transports.len());
|
||||
for transport in request.transports.iter().copied() {
|
||||
if !transports.contains(&transport) {
|
||||
transports.push(transport);
|
||||
}
|
||||
}
|
||||
if transports.is_empty() {
|
||||
return ListenerProbeOutcome::Invalid {
|
||||
message: "listener transport list cannot be empty".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let claims = addresses
|
||||
.into_iter()
|
||||
.flat_map(|address| {
|
||||
transports
|
||||
.iter()
|
||||
.copied()
|
||||
.map(move |transport| BindClaim::new("listener", address, port, transport))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
probe_claims(&claims)
|
||||
}
|
||||
|
||||
pub(crate) fn probe_proxy_port_change(
|
||||
current: &Mapping,
|
||||
candidate: &Mapping,
|
||||
current_core_is_running: bool,
|
||||
) -> ListenerProbeOutcome {
|
||||
let current_claims = if current_core_is_running {
|
||||
match proxy_claims(current) {
|
||||
Ok(claims) => claims,
|
||||
Err(error) => {
|
||||
return ListenerProbeOutcome::Invalid {
|
||||
message: format!("current proxy listener configuration is invalid: {error:#}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut candidate_claims = match proxy_claims(candidate) {
|
||||
Ok(claims) => claims,
|
||||
Err(error) => {
|
||||
return ListenerProbeOutcome::Invalid {
|
||||
message: format!("candidate proxy listener configuration is invalid: {error:#}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(conflict) = internal_conflict(&candidate_claims) {
|
||||
return conflict;
|
||||
}
|
||||
|
||||
// A running core releases its proxy sockets during the coordinated restart.
|
||||
candidate_claims.retain(|candidate| !current_claims.iter().any(|current| claims_overlap(current, candidate)));
|
||||
probe_claims(&candidate_claims)
|
||||
}
|
||||
|
||||
fn proxy_claims(config: &Mapping) -> Result<Vec<BindClaim>> {
|
||||
let addresses = proxy_bind_addresses(config)?;
|
||||
let mut claims = Vec::new();
|
||||
for (key, name, transports) in PROXY_LISTENERS {
|
||||
let Some(port) = mapping_port(config, key)? else {
|
||||
continue;
|
||||
};
|
||||
for address in &addresses {
|
||||
for transport in transports {
|
||||
claims.push(BindClaim::new(name, *address, port, *transport));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
|
||||
let ipv6 = config.get("ipv6").and_then(Value::as_bool).unwrap_or(true);
|
||||
let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
|
||||
|
||||
if !allow_lan {
|
||||
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||
}
|
||||
|
||||
let bind_address = config
|
||||
.get("bind-address")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|address| !address.is_empty())
|
||||
.unwrap_or("*");
|
||||
if bind_address == "*" {
|
||||
let mut addresses = vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)];
|
||||
if ipv6 {
|
||||
addresses.push(IpAddr::V6(Ipv6Addr::UNSPECIFIED));
|
||||
}
|
||||
return Ok(addresses);
|
||||
}
|
||||
if bind_address.eq_ignore_ascii_case("localhost") {
|
||||
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||
}
|
||||
|
||||
let normalized = bind_address
|
||||
.strip_prefix('[')
|
||||
.and_then(|address| address.strip_suffix(']'))
|
||||
.unwrap_or(bind_address);
|
||||
Ok(vec![
|
||||
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
||||
])
|
||||
}
|
||||
|
||||
fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
|
||||
let address = address.trim();
|
||||
if let Ok(socket) = SocketAddr::from_str(address) {
|
||||
if socket.port() == 0 {
|
||||
bail!("listener port must be between 1 and 65535");
|
||||
}
|
||||
return Ok((vec![socket.ip()], socket.port()));
|
||||
}
|
||||
|
||||
let (host, port) = address
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow!("listener address must include a port"))?;
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("invalid listener port {port:?}"))?;
|
||||
if port == 0 {
|
||||
bail!("listener port must be between 1 and 65535");
|
||||
}
|
||||
let host = host
|
||||
.trim()
|
||||
.strip_prefix('[')
|
||||
.and_then(|host| host.strip_suffix(']'))
|
||||
.unwrap_or_else(|| host.trim());
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
||||
}
|
||||
Ok((
|
||||
vec![IpAddr::from_str(host).with_context(|| format!("invalid listener host {host:?}"))?],
|
||||
port,
|
||||
))
|
||||
}
|
||||
|
||||
fn mapping_port(config: &Mapping, key: &str) -> Result<Option<u16>> {
|
||||
let Some(value) = config.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let port = match value {
|
||||
Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
|
||||
Value::String(port) => port.parse().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| anyhow!("{key} must be an integer between 1 and 65535"))?;
|
||||
if port == 0 {
|
||||
bail!("{key} must be an integer between 1 and 65535");
|
||||
}
|
||||
Ok(Some(port))
|
||||
}
|
||||
|
||||
fn internal_conflict(claims: &[BindClaim]) -> Option<ListenerProbeOutcome> {
|
||||
for (index, claim) in claims.iter().enumerate() {
|
||||
if claims[..index].iter().any(|existing| claims_overlap(existing, claim)) {
|
||||
return Some(conflict_outcome(claim));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool {
|
||||
left.port == right.port
|
||||
&& left.transport == right.transport
|
||||
&& left.address.is_ipv4() == right.address.is_ipv4()
|
||||
&& (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified())
|
||||
}
|
||||
|
||||
fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
|
||||
let mut sockets = Vec::with_capacity(claims.len());
|
||||
for claim in claims {
|
||||
match bind_claim(claim) {
|
||||
Ok(socket) => sockets.push(socket),
|
||||
Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::InvalidInput | io::ErrorKind::Unsupported
|
||||
) =>
|
||||
{
|
||||
return ListenerProbeOutcome::Invalid {
|
||||
message: format!(
|
||||
"{} cannot listen on {} over {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
transport_name(claim.transport),
|
||||
error
|
||||
),
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
return ListenerProbeOutcome::Indeterminate {
|
||||
message: format!(
|
||||
"unable to verify {} on {} over {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
transport_name(claim.transport),
|
||||
error
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(sockets.len(), claims.len());
|
||||
ListenerProbeOutcome::Available
|
||||
}
|
||||
|
||||
fn bind_claim(claim: &BindClaim) -> io::Result<Socket> {
|
||||
let domain = if claim.address.is_ipv4() {
|
||||
Domain::IPV4
|
||||
} else {
|
||||
Domain::IPV6
|
||||
};
|
||||
let (socket_type, protocol) = match claim.transport {
|
||||
ListenerTransport::Tcp => (Type::STREAM, Protocol::TCP),
|
||||
ListenerTransport::Udp => (Type::DGRAM, Protocol::UDP),
|
||||
};
|
||||
let socket = Socket::new(domain, socket_type, Some(protocol))?;
|
||||
if claim.address.is_ipv6() {
|
||||
socket.set_only_v6(true)?;
|
||||
}
|
||||
socket.set_reuse_address(false)?;
|
||||
#[cfg(windows)]
|
||||
set_exclusive_address_use(&socket)?;
|
||||
socket.bind(&SockAddr::from(claim.socket_addr()))?;
|
||||
if claim.transport == ListenerTransport::Tcp {
|
||||
socket.listen(1)?;
|
||||
}
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn is_bind_conflict(error: &io::Error) -> bool {
|
||||
if error.kind() == io::ErrorKind::AddrInUse {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Win32::Networking::WinSock::WSAEACCES;
|
||||
|
||||
error.raw_os_error() == Some(WSAEACCES)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn set_exclusive_address_use(socket: &Socket) -> io::Result<()> {
|
||||
use std::{mem::size_of, os::windows::io::AsRawSocket as _};
|
||||
use windows_sys::Win32::Networking::WinSock::{SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, setsockopt};
|
||||
|
||||
let enabled = 1i32;
|
||||
// Rust exposes `RawSocket` as `u64` for compatibility, while WinSock's
|
||||
// `SOCKET` is pointer-sized. The value originated from WinSock, so it fits
|
||||
// in `SOCKET` on every Windows target.
|
||||
let raw_socket = socket.as_raw_socket() as SOCKET;
|
||||
// SAFETY: the socket is valid and `enabled` remains alive for the duration of the call.
|
||||
let result = unsafe {
|
||||
setsockopt(
|
||||
raw_socket,
|
||||
SOL_SOCKET,
|
||||
SO_EXCLUSIVEADDRUSE,
|
||||
(&raw const enabled).cast(),
|
||||
size_of::<i32>() as i32,
|
||||
)
|
||||
};
|
||||
if result == SOCKET_ERROR {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const fn conflict_outcome(claim: &BindClaim) -> ListenerProbeOutcome {
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port: claim.port,
|
||||
transport: claim.transport,
|
||||
}
|
||||
}
|
||||
|
||||
const fn transport_name(transport: ListenerTransport) -> &'static str {
|
||||
match transport {
|
||||
ListenerTransport::Tcp => "TCP",
|
||||
ListenerTransport::Udp => "UDP",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener,
|
||||
probe_proxy_port_change,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_yaml_ng::Mapping;
|
||||
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
|
||||
|
||||
fn mapping(yaml: &str) -> anyhow::Result<Mapping> {
|
||||
Ok(serde_yaml_ng::from_str(yaml)?)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_tcp_conflicts_on_the_requested_address() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let outcome = probe_listener(&ListenerProbe {
|
||||
address: format!("127.0.0.1:{port}"),
|
||||
transports: vec![ListenerTransport::Tcp],
|
||||
});
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port,
|
||||
transport: ListenerTransport::Tcp
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_udp_conflicts_on_the_requested_address() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
let outcome = probe_listener(&ListenerProbe {
|
||||
address: format!("127.0.0.1:{port}"),
|
||||
transports: vec![ListenerTransport::Udp],
|
||||
});
|
||||
assert_eq!(
|
||||
outcome,
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port,
|
||||
transport: ListenerTransport::Udp
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_does_not_treat_invalid_addresses_as_available() {
|
||||
let outcome = probe_listener(&ListenerProbe {
|
||||
address: "not-a-listener".into(),
|
||||
transports: vec![ListenerTransport::Tcp],
|
||||
});
|
||||
assert!(matches!(outcome, ListenerProbeOutcome::Invalid { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localhost_matches_mihomo_ipv4_resolution() -> anyhow::Result<()> {
|
||||
let (addresses, port) = super::parse_listener_address("localhost:7890")?;
|
||||
assert_eq!(addresses, vec![std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||
assert_eq!(port, 7890);
|
||||
|
||||
let proxy_addresses =
|
||||
super::proxy_bind_addresses(&mapping("allow-lan: true\nbind-address: localhost\nipv6: true\n")?)?;
|
||||
assert_eq!(proxy_addresses, addresses);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_probe_skips_sockets_owned_by_the_running_core() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let runtime = mapping(&format!("ipv6: false\nmixed-port: {port}\n"))?;
|
||||
assert_eq!(
|
||||
probe_proxy_port_change(&runtime, &runtime, true),
|
||||
ListenerProbeOutcome::Available
|
||||
);
|
||||
assert!(matches!(
|
||||
probe_proxy_port_change(&runtime, &runtime, false),
|
||||
ListenerProbeOutcome::Conflict { .. }
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_probe_rejects_duplicate_candidate_ports() -> anyhow::Result<()> {
|
||||
let current = mapping("ipv6: false\nmixed-port: 31000\n")?;
|
||||
let candidate = mapping("ipv6: false\nmixed-port: 31001\nsocks-port: 31001\n")?;
|
||||
assert_eq!(
|
||||
probe_proxy_port_change(¤t, &candidate, true),
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port: 31001,
|
||||
transport: ListenerTransport::Tcp
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_probe_checks_udp_for_mixed_and_socks_ports() -> anyhow::Result<()> {
|
||||
for key in ["mixed-port", "socks-port"] {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
// Exclude the running core's TCP claim so this assertion isolates UDP
|
||||
// without depending on process-global TCP port availability.
|
||||
let current = mapping(&format!("ipv6: false\nport: {port}\n"))?;
|
||||
let candidate = mapping(&format!("ipv6: false\n{key}: {port}\n"))?;
|
||||
assert_eq!(
|
||||
probe_proxy_port_change(¤t, &candidate, true),
|
||||
ListenerProbeOutcome::Conflict {
|
||||
port,
|
||||
transport: ListenerTransport::Udp
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scope_uses_the_runtime_lan_binding() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let scope =
|
||||
ListenerBindScope::from_mapping(&mapping("allow-lan: true\nbind-address: 0.0.0.0\nipv6: false\n")?)?;
|
||||
assert!(!scope.mixed_port_is_available(port));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?;
|
||||
assert!(!scope.mixed_port_is_available(port));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_settings_keep_the_camel_case_command_contract() -> anyhow::Result<()> {
|
||||
let settings = serde_json::from_value::<super::ProxyPortSettings>(json!({
|
||||
"mixedPort": 7897,
|
||||
"socks": { "enabled": false, "port": 7898 },
|
||||
"http": { "enabled": false, "port": 7899 },
|
||||
"redir": { "enabled": false, "port": 7895 },
|
||||
"tproxy": { "enabled": false, "port": 7896 }
|
||||
}))?;
|
||||
assert_eq!(settings.mixed_port, 7897);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use std::{
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::WriterConfig;
|
||||
use compact_str::CompactString;
|
||||
use flexi_logger::{
|
||||
Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle,
|
||||
@@ -18,11 +17,15 @@ use log::{Level, LevelFilter, Record};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
|
||||
use crate::{
|
||||
core::service,
|
||||
core::{CoreManager, manager::RunningMode, service},
|
||||
singleton,
|
||||
utils::dirs::{self, service_log_dir, sidecar_log_dir},
|
||||
utils::dirs::{self, sidecar_log_dir},
|
||||
};
|
||||
|
||||
const fn should_sync_service_writer(running_mode: RunningMode) -> bool {
|
||||
matches!(running_mode, RunningMode::Service)
|
||||
}
|
||||
|
||||
pub struct Logger {
|
||||
handle: Arc<Mutex<Option<LoggerHandle>>>,
|
||||
sidecar_file_writer: Arc<RwLock<Option<FileLogWriter>>>,
|
||||
@@ -91,7 +94,10 @@ impl Logger {
|
||||
filter_modules.push("tauri");
|
||||
#[cfg(feature = "tracing")]
|
||||
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]);
|
||||
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
|
||||
let logger = logger.filter(Box::new(clash_verge_logging::ModuleFilter::new(
|
||||
filter_modules,
|
||||
Some(vec!["tauri_plugin_mihomo"]),
|
||||
)));
|
||||
|
||||
let handle = logger.start()?;
|
||||
*self.handle.lock() = Some(handle);
|
||||
@@ -179,15 +185,17 @@ impl Logger {
|
||||
let sidecar_writer = self.generate_sidecar_writer()?;
|
||||
*self.sidecar_file_writer.write() = Some(sidecar_writer);
|
||||
|
||||
// update service writer config
|
||||
if service::is_service_ipc_path_exists() && service::is_service_available().await.is_ok() {
|
||||
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
|
||||
clash_verge_service_ipc::update_writer(&WriterConfig {
|
||||
directory: service_log_dir,
|
||||
// The service writer is auxiliary to the local logger. Synchronize it only
|
||||
// for an active service session and do not roll back local settings on failure.
|
||||
if should_sync_service_writer(*CoreManager::global().get_running_mode())
|
||||
&& let Err(error) = service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig {
|
||||
directory: String::new(),
|
||||
max_log_size: log_max_size * 1024,
|
||||
max_log_files: log_max_count,
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
logging!(warn, Type::Service, "failed to update service writer config: {error:#}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -225,17 +233,17 @@ impl Logger {
|
||||
logging!(error, Type::System, "failed to get sidecar file log writer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_writer_config(&self) -> Result<WriterConfig> {
|
||||
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
|
||||
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
|
||||
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
|
||||
let writer_config = WriterConfig {
|
||||
directory: service_log_dir,
|
||||
max_log_size: log_max_size * 1024,
|
||||
max_log_files: log_max_count,
|
||||
};
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_sync_service_writer;
|
||||
use crate::core::manager::RunningMode;
|
||||
|
||||
Ok(writer_config)
|
||||
#[test]
|
||||
fn service_writer_sync_requires_service_running_mode() {
|
||||
assert!(should_sync_service_writer(RunningMode::Service));
|
||||
assert!(!should_sync_service_writer(RunningMode::Sidecar));
|
||||
assert!(!should_sync_service_writer(RunningMode::NotRunning));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::CoreManager;
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::core::service::StageRequest;
|
||||
use crate::{
|
||||
config::{Config, ConfigType, runtime::IRuntime},
|
||||
constants::timing,
|
||||
@@ -9,12 +10,154 @@ use crate::{
|
||||
utils::{dirs, help},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::StageRuntimeOutcome;
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{collections::HashSet, path::PathBuf, time::Instant};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_mihomo::Error as MihomoError;
|
||||
|
||||
/// What came back from asking the Service to stage a runtime.
|
||||
///
|
||||
/// Separated from the decision below so the decision stays a pure function of it. The variants are
|
||||
/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude:
|
||||
/// a refusal is the Service's verdict on this bundle, while silence says nothing at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum StageAttempt {
|
||||
/// The Service that owns the running Core predates staging.
|
||||
Unsupported,
|
||||
/// The Service answered, and its answer was a refusal about the bundle itself — the kind a
|
||||
/// fresh start would run into again, because it would materialise the same bundle.
|
||||
RefusedTheBundle(String),
|
||||
/// The Service answered with a refusal about something other than the bundle: the session, the
|
||||
/// protocol, the request. Starting is how several of those get resolved, so it is not a reason
|
||||
/// to leave the Core where it is.
|
||||
RefusedForAnotherReason(String),
|
||||
/// The request did not come back. The Service is authoritative about its own runtime, so
|
||||
/// without an answer nothing may be assumed about what it did or did not write.
|
||||
Unanswered(String),
|
||||
Answered(StageRuntimeOutcome),
|
||||
}
|
||||
|
||||
/// How the Core should be made to pick up a configuration.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ConfigApplication {
|
||||
/// Point the running Core at this path.
|
||||
ReloadFrom(String),
|
||||
/// Stop the Core and start it again from a freshly materialised runtime.
|
||||
ReplaceCore,
|
||||
/// Leave the Core alone and report why the configuration cannot be applied.
|
||||
Fail(String),
|
||||
}
|
||||
|
||||
/// Decide how to apply a configuration, given how staging went.
|
||||
///
|
||||
/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a
|
||||
/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start
|
||||
/// materialises the same bundle — so it would fail the same way, having stopped a Core that was
|
||||
/// working.
|
||||
///
|
||||
/// The distinction matters more than it looks. A refusal about the session is not a refusal about
|
||||
/// the bundle: starting proposes a new session rather than presenting the old one, and the restart
|
||||
/// path is also what notices that ownership was lost and clears the system proxy. Treating every
|
||||
/// non-zero code alike would skip that.
|
||||
///
|
||||
/// Everything else — an older Service, a request that never came back, or a Service that asked to
|
||||
/// be restarted — replaces the Core, which is what this did before staging existed and what the
|
||||
/// Service guarantees is still safe after every way of declining.
|
||||
fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication {
|
||||
match attempt {
|
||||
StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => {
|
||||
ConfigApplication::ReloadFrom(config_path.into())
|
||||
}
|
||||
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"Service declined to stage the runtime ({reason:?}); replacing the core instead"
|
||||
);
|
||||
ConfigApplication::ReplaceCore
|
||||
}
|
||||
StageAttempt::RefusedTheBundle(message) => ConfigApplication::Fail(message.clone()),
|
||||
StageAttempt::RefusedForAnotherReason(message) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Service refused to stage the runtime ({message}); replacing the core instead"
|
||||
);
|
||||
ConfigApplication::ReplaceCore
|
||||
}
|
||||
StageAttempt::Unanswered(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Failed to stage the service runtime, replacing the core instead: {error}"
|
||||
);
|
||||
ConfigApplication::ReplaceCore
|
||||
}
|
||||
StageAttempt::Unsupported => ConfigApplication::ReplaceCore,
|
||||
}
|
||||
}
|
||||
|
||||
/// One round trip to the Service, classified but not judged.
|
||||
async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
|
||||
match crate::core::service::stage_runtime_by_service(path).await {
|
||||
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
|
||||
Ok(StageRequest::Refused { code, message }) => {
|
||||
let message = message.to_string().into();
|
||||
if StageRequest::is_about_the_bundle(code) {
|
||||
StageAttempt::RefusedTheBundle(message)
|
||||
} else {
|
||||
StageAttempt::RefusedForAnotherReason(message)
|
||||
}
|
||||
}
|
||||
Err(err) => StageAttempt::Unanswered(format!("{err:#}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask once, and ask again only when the first attempt came back silent.
|
||||
///
|
||||
/// Silence is the one answer that has to be resolved rather than acted on. The Service commits the
|
||||
/// generation before it replies, so a reply that never arrives can leave `config.yaml` already
|
||||
/// replaced while this side concludes nothing happened — and the Service restarts a Core that dies
|
||||
/// later from exactly that file. Treating silence as "nothing happened" is therefore a guess, and
|
||||
/// the expensive half of it is silent: the Core keeps running the previous configuration until
|
||||
/// something restarts it, and then it does not.
|
||||
///
|
||||
/// Asking again is cheap precisely where it matters. Staging plans against the manifest it wrote,
|
||||
/// so a bundle that already landed copies nothing and answers in milliseconds; and because the
|
||||
/// Service holds its lifecycle lock for the whole operation, a second ask that overtakes a first
|
||||
/// one still in flight waits for it rather than interleaving with it. `confirm_within` is what
|
||||
/// keeps that wait from being the caller's problem — past it, the answer has stopped being worth
|
||||
/// more than the fallback, which is where a second silence goes anyway.
|
||||
async fn stage_with_confirmation<Ask, Fut>(confirm_within: Duration, ask: Ask) -> StageAttempt
|
||||
where
|
||||
Ask: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = StageAttempt>,
|
||||
{
|
||||
let first = match ask().await {
|
||||
StageAttempt::Unanswered(first) => first,
|
||||
answered => return answered,
|
||||
};
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Staging did not answer ({first}); asking once more before replacing the core"
|
||||
);
|
||||
match tokio::time::timeout(confirm_within, ask()).await {
|
||||
Ok(StageAttempt::Unanswered(again)) => {
|
||||
StageAttempt::Unanswered(format!("{first}; asked again: {again}").into())
|
||||
}
|
||||
Ok(answered) => answered,
|
||||
Err(_) => StageAttempt::Unanswered(format!("{first}; the second ask did not answer either").into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
|
||||
use crate::constants::files::RUNTIME_CONFIG;
|
||||
@@ -92,13 +235,15 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
async fn perform_config_update(&self) -> Result<ValidationOutcome> {
|
||||
let runtime = Config::runtime().await;
|
||||
let transaction = DraftTransaction::begin(vec![&runtime])?;
|
||||
|
||||
if let Err(err) = Config::generate().await {
|
||||
let message: String = err.to_string().into();
|
||||
Config::runtime().await.discard();
|
||||
return Ok(ValidationOutcome::invalid_from_message(message));
|
||||
}
|
||||
|
||||
self.apply_generate_config_inner().await
|
||||
self.validate_and_apply(transaction).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_runtime_config<F>(&self, f: F) -> Result<ValidationOutcome>
|
||||
@@ -113,59 +258,343 @@ impl CoreManager {
|
||||
self.finish_config_update();
|
||||
}
|
||||
|
||||
Config::runtime().await.edit_draft(f);
|
||||
self.apply_generate_config_inner().await
|
||||
let runtime = Config::runtime().await;
|
||||
let transaction = DraftTransaction::begin(vec![&runtime])?;
|
||||
runtime.edit_draft(f);
|
||||
self.validate_and_apply(transaction).await
|
||||
}
|
||||
|
||||
async fn apply_generate_config_inner(&self) -> Result<ValidationOutcome> {
|
||||
match CoreConfigValidator::global().validate_config_outcome().await {
|
||||
Ok(outcome) if outcome.is_valid() => {
|
||||
let run_path = Config::generate_file(ConfigType::Run).await?;
|
||||
self.apply_config(run_path).await?;
|
||||
Ok(ValidationOutcome::Valid)
|
||||
}
|
||||
Ok(outcome) => {
|
||||
Config::runtime().await.discard();
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(e) => {
|
||||
Config::runtime().await.discard();
|
||||
Err(e)
|
||||
}
|
||||
/// Validate the staged Runtime Config and hand it to the Core, committing only if both work.
|
||||
///
|
||||
/// Takes the transaction rather than opening one, so it covers the staging its callers did.
|
||||
/// Every way out of here other than the last line rolls that staging back.
|
||||
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
|
||||
let outcome = CoreConfigValidator::global().validate_config_outcome().await?;
|
||||
if !outcome.is_valid() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let run_path = Config::generate_file(ConfigType::Run).await?;
|
||||
self.apply_config(run_path).await?;
|
||||
transaction.commit();
|
||||
Ok(ValidationOutcome::Valid)
|
||||
}
|
||||
|
||||
/// Hand the generated configuration to the Core.
|
||||
///
|
||||
/// Says nothing about drafts: whether the staged Runtime Config is kept follows from
|
||||
/// whether this succeeded, and the caller's transaction decides that.
|
||||
///
|
||||
/// Both modes have the same shape — put the configuration where the Core can read it, ask the
|
||||
/// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar
|
||||
/// mode the Core already reads the app's own directory, while in Service mode the Service has
|
||||
/// to materialise the configuration into the directory it started the Core in.
|
||||
async fn apply_config(&self, path: PathBuf) -> Result<()> {
|
||||
let path = dirs::path_to_str(&path)?;
|
||||
match self.reload_config(path).await {
|
||||
Ok(_) => {
|
||||
Config::runtime().await.apply();
|
||||
logging!(info, Type::Core, "Configuration applied");
|
||||
Ok(())
|
||||
if matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
let _lifecycle = self.lifecycle_lock.lock().await;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
return Err(anyhow!("core mode changed while applying service configuration"));
|
||||
}
|
||||
Err(err) => {
|
||||
return self.apply_config_by_service(&path).await;
|
||||
}
|
||||
|
||||
let path = dirs::path_to_str(&path)?;
|
||||
self.reload_or_restart(path).await
|
||||
}
|
||||
|
||||
/// Apply a configuration in Service mode, staging it in place when that is possible.
|
||||
///
|
||||
/// Every way of not staging leads to the same place it always led: stop the Core and start it
|
||||
/// again from a freshly materialised runtime. That is what makes declining safe — the Service
|
||||
/// leaves the runtime it could not stage exactly as the running Core left it.
|
||||
///
|
||||
/// Caller must hold `lifecycle_lock`.
|
||||
async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> {
|
||||
match plan_config_application(&self.attempt_staging(path).await) {
|
||||
ConfigApplication::Fail(message) => {
|
||||
// The Service looked at this bundle and would not take it. Replacing the Core would
|
||||
// hand it the same bundle, so the outage would buy nothing.
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}"
|
||||
"Service refused the runtime, leaving the core running: {message}"
|
||||
);
|
||||
match self.restart_core().await {
|
||||
Ok(_) => {
|
||||
Config::runtime().await.apply();
|
||||
logging!(info, Type::Core, "Configuration applied after restart");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::Core, "Failed to restart core: {}", err);
|
||||
Config::runtime().await.discard();
|
||||
Err(anyhow!("Failed to apply config: {}", err))
|
||||
}
|
||||
return Err(anyhow!("{message}"));
|
||||
}
|
||||
ConfigApplication::ReloadFrom(staged) => match self.reload_config(&staged).await {
|
||||
Ok(()) => {
|
||||
logging!(info, Type::Core, "Configuration staged and applied by service");
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Failed to reload the staged service runtime, replacing the core instead: {err}"
|
||||
),
|
||||
},
|
||||
ConfigApplication::ReplaceCore => {}
|
||||
}
|
||||
|
||||
self.replace_service_core_with_config(path).await?;
|
||||
logging!(info, Type::Core, "Configuration materialized and applied by service");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ask the Service to stage the runtime, reporting the attempt rather than judging it.
|
||||
async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt {
|
||||
if !crate::core::service::active_service_supports_runtime_staging() {
|
||||
return StageAttempt::Unsupported;
|
||||
}
|
||||
stage_with_confirmation(timing::STAGE_CONFIRM_TIMEOUT, || ask_to_stage(path)).await
|
||||
}
|
||||
|
||||
/// Reload the Core from `path`, and replace the Core if it will not take it.
|
||||
async fn reload_or_restart(&self, path: &str) -> Result<()> {
|
||||
let Err(err) = self.reload_config(path).await else {
|
||||
logging!(info, Type::Core, "Configuration applied");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}"
|
||||
);
|
||||
match self.restart_core().await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Core, "Configuration applied after restart");
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(error, Type::Core, "Failed to restart core: {}", err);
|
||||
Err(anyhow!("Failed to apply config: {}", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reload_config(&self, path: &str) -> Result<(), MihomoError> {
|
||||
handle::Handle::mihomo().await.reload_config(true, path).await
|
||||
handle::Handle::mihomo().reload_config(true, path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConfigApplication, StageAttempt, StageRequest, plan_config_application, stage_with_confirmation};
|
||||
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
|
||||
use std::{cell::Cell, time::Duration};
|
||||
|
||||
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
|
||||
|
||||
fn staged() -> StageAttempt {
|
||||
StageAttempt::Answered(StageRuntimeOutcome::Staged {
|
||||
config_path: "/service/runtime.generation-1/config.yaml".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staged_runtime_is_reloaded_from_where_the_service_put_it() {
|
||||
let attempt = StageAttempt::Answered(StageRuntimeOutcome::Staged {
|
||||
config_path: "/service/runtime.generation-1/config.yaml".to_owned(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
plan_config_application(&attempt),
|
||||
ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()),
|
||||
"the core must be pointed at the service's copy, never at the app's own"
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrap a rejection as the answer the Service would have sent.
|
||||
///
|
||||
/// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service
|
||||
/// to decline cannot be added upstream without this failing to compile — which is what sends
|
||||
/// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached
|
||||
/// production without one, and inherited its verdict silently.
|
||||
fn declined(reason: StageRejection) -> StageAttempt {
|
||||
match &reason {
|
||||
StageRejection::CoreNotRunning
|
||||
| StageRejection::CorePathChanged
|
||||
| StageRejection::RuntimeUnwritable { .. }
|
||||
| StageRejection::CoreRestarted => {}
|
||||
}
|
||||
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_that_declined_makes_the_core_be_replaced() {
|
||||
for reason in [
|
||||
StageRejection::CoreNotRunning,
|
||||
StageRejection::CorePathChanged,
|
||||
StageRejection::RuntimeUnwritable {
|
||||
detail: "held open".to_owned(),
|
||||
},
|
||||
// The service's own watchdog can restart the core mid-staging, which leaves the
|
||||
// generation holding a mixture no manifest describes. Rebuilding it from nothing is
|
||||
// the only answer that does not guess at what is in there.
|
||||
StageRejection::CoreRestarted,
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_config_application(&declined(reason.clone())),
|
||||
ConfigApplication::ReplaceCore,
|
||||
"declining is an outcome, not an error: {reason:?} must fall back, not fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_too_old_to_stage_makes_the_core_be_replaced() {
|
||||
assert_eq!(
|
||||
plan_config_application(&StageAttempt::Unsupported),
|
||||
ConfigApplication::ReplaceCore,
|
||||
"an installed service without staging must keep working, not demand a reinstall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_about_the_bundle_leaves_the_core_alone() {
|
||||
// Replacing the core would materialise the same bundle the Service just rejected, so the
|
||||
// outage buys nothing: the configuration update fails either way, and this way the user's
|
||||
// proxy is still working when it does.
|
||||
assert_eq!(
|
||||
plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())),
|
||||
ConfigApplication::Fail("runtime asset is unavailable".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_about_anything_else_still_replaces_the_core() {
|
||||
// A stale session is the case that matters: starting proposes a new one, so it is the cure
|
||||
// rather than a repeat of the failure — and the restart path is also what notices ownership
|
||||
// was lost and clears the system proxy.
|
||||
assert_eq!(
|
||||
plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())),
|
||||
ConfigApplication::ReplaceCore
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_bundle_codes_count_as_a_refusal_of_the_bundle() {
|
||||
use clash_verge_service_ipc::ServiceErrorCode;
|
||||
|
||||
assert!(StageRequest::is_about_the_bundle(
|
||||
ServiceErrorCode::InvalidRuntimeAsset as u16
|
||||
));
|
||||
assert!(StageRequest::is_about_the_bundle(
|
||||
ServiceErrorCode::InvalidInstallLocation as u16
|
||||
));
|
||||
for other in [
|
||||
ServiceErrorCode::StaleOwnerSession,
|
||||
ServiceErrorCode::UnauthorizedOwner,
|
||||
ServiceErrorCode::ProtocolMismatch,
|
||||
ServiceErrorCode::NotActive,
|
||||
] {
|
||||
assert!(
|
||||
!StageRequest::is_about_the_bundle(other as u16),
|
||||
"{other:?} says nothing about the bundle, so starting must still be tried"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_never_came_back_makes_the_core_be_replaced() {
|
||||
// Not knowing what the service did is the one case where reloading would be a guess: the
|
||||
// staged path might not exist, or might hold the previous configuration.
|
||||
assert_eq!(
|
||||
plan_config_application(&StageAttempt::Unanswered("connection reset".into())),
|
||||
ConfigApplication::ReplaceCore
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() {
|
||||
// Staging is only idempotent in what it writes, not in what it costs: a second ask after a
|
||||
// perfectly good answer would re-send the whole bundle for nothing.
|
||||
for answer in [
|
||||
staged(),
|
||||
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired {
|
||||
reason: StageRejection::CoreNotRunning,
|
||||
}),
|
||||
StageAttempt::RefusedTheBundle("runtime asset is unavailable".into()),
|
||||
StageAttempt::RefusedForAnotherReason("owner session is stale".into()),
|
||||
] {
|
||||
let asks = Cell::new(0);
|
||||
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
|
||||
asks.set(asks.get() + 1);
|
||||
std::future::ready(answer.clone())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, answer);
|
||||
assert_eq!(asks.get(), 1, "{answer:?} was answered, so asking again buys nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn silence_is_resolved_by_asking_again_rather_than_assumed() {
|
||||
// The case this exists for: the service committed the generation and the reply was lost.
|
||||
// Asking again reaches a service that has already done the work, so the second answer is
|
||||
// the truth the first one failed to deliver — and it keeps the core off the replace path.
|
||||
let asks = Cell::new(0);
|
||||
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
|
||||
asks.set(asks.get() + 1);
|
||||
std::future::ready(match asks.get() {
|
||||
1 => StageAttempt::Unanswered("connection reset".into()),
|
||||
_ => staged(),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
staged(),
|
||||
"the second answer must decide, not the first silence"
|
||||
);
|
||||
assert_eq!(asks.get(), 2);
|
||||
assert_eq!(
|
||||
plan_config_application(&outcome),
|
||||
ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()),
|
||||
"resolving the silence is only worth doing if it changes what happens next"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_silence_still_replaces_the_core() {
|
||||
let asks = Cell::new(0);
|
||||
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
|
||||
asks.set(asks.get() + 1);
|
||||
std::future::ready(StageAttempt::Unanswered("connection reset".into()))
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(asks.get(), 2, "asking a third time would just be a retry loop");
|
||||
assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore);
|
||||
assert!(
|
||||
matches!(&outcome, StageAttempt::Unanswered(detail)
|
||||
if detail.contains("connection reset") && detail.contains("asked again")),
|
||||
"two silences must stay unanswered and record that both were tried, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_second_ask_that_keeps_working_is_not_waited_out() {
|
||||
// A service still copying assets is why the deadline exists: the answer would be worth
|
||||
// having, but not at the cost of leaving the user's configuration unapplied indefinitely.
|
||||
let asks = Cell::new(0);
|
||||
let started = tokio::time::Instant::now();
|
||||
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || async {
|
||||
asks.set(asks.get() + 1);
|
||||
match asks.get() {
|
||||
1 => StageAttempt::Unanswered("deadline has elapsed".into()),
|
||||
_ => std::future::pending().await,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore);
|
||||
assert_eq!(
|
||||
started.elapsed(),
|
||||
CONFIRM_WITHIN,
|
||||
"the caller must be released at the deadline, not at the service's convenience"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,24 +5,56 @@ mod state;
|
||||
use anyhow::Result;
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use clash_verge_logger::AsyncLogger;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::{
|
||||
fmt,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
|
||||
use crate::core::runstate::{RUN_STATE, RealEnv, RunStateStore};
|
||||
use crate::singleton;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
|
||||
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
|
||||
|
||||
#[derive(Debug, serde::Serialize, PartialEq, Eq)]
|
||||
const CORE_READINESS_ACTIVE_BIT: u64 = 1;
|
||||
const CORE_READINESS_GENERATION_STEP: u64 = 1 << 1;
|
||||
|
||||
const fn next_active_core_readiness_state(state: u64) -> u64 {
|
||||
(state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT) | CORE_READINESS_ACTIVE_BIT
|
||||
}
|
||||
|
||||
const fn inactive_core_readiness_state(state: u64) -> u64 {
|
||||
state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT
|
||||
}
|
||||
|
||||
const fn active_core_readiness_generation(state: u64) -> Option<u64> {
|
||||
if state & CORE_READINESS_ACTIVE_BIT != 0 {
|
||||
Some(state)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64) -> bool {
|
||||
state
|
||||
.compare_exchange(
|
||||
captured_generation,
|
||||
inactive_core_readiness_state(captured_generation),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
pub enum RunningMode {
|
||||
Service,
|
||||
Sidecar,
|
||||
@@ -41,42 +73,44 @@ impl fmt::Display for RunningMode {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoreManager {
|
||||
/// The Run State this manager reports transitions to.
|
||||
///
|
||||
/// A reference rather than the global directly, so a test can supervise a Core without
|
||||
/// racing every other test for one process-wide Running Mode.
|
||||
run_state: &'static RunStateStore<RealEnv>,
|
||||
state: ArcSwap<State>,
|
||||
last_update: ArcSwapOption<Instant>,
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||
lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
// sidecar→service 交接 watcher 单实例标志。
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Process-level state owned by `CoreManager`.
|
||||
///
|
||||
/// Running Mode deliberately does *not* live here — it belongs to `core::runstate`, which
|
||||
/// keeps it consistent with Service Health and derives PAC availability from it.
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
running_mode: ArcSwap<RunningMode>,
|
||||
child_sidecar: ArcSwapOption<CommandChild>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
running_mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)),
|
||||
child_sidecar: ArcSwapOption::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CoreManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
run_state: &RUN_STATE,
|
||||
state: ArcSwap::new(Arc::new(State::default())),
|
||||
last_update: ArcSwapOption::new(None),
|
||||
#[cfg(target_os = "windows")]
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
core_readiness_state: AtomicU64::new(0),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool::new(false),
|
||||
@@ -89,8 +123,17 @@ impl CoreManager {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A manager with its own Run State, for tests that must not disturb the process-wide one.
|
||||
#[cfg(test)]
|
||||
fn isolated() -> Self {
|
||||
Self {
|
||||
run_state: Box::leak(Box::new(RunStateStore::new(RealEnv))),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_running_mode(&self) -> Arc<RunningMode> {
|
||||
Arc::clone(&self.state.load().running_mode.load())
|
||||
self.run_state.mode_arc()
|
||||
}
|
||||
|
||||
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
|
||||
@@ -101,13 +144,42 @@ impl CoreManager {
|
||||
.and_then(|arc| Arc::try_unwrap(arc).ok())
|
||||
}
|
||||
|
||||
pub fn get_running_sidecar_pid(&self) -> Option<u32> {
|
||||
self.state.load().child_sidecar.load_full().map(|child| child.pid())
|
||||
}
|
||||
|
||||
pub fn get_last_update(&self) -> Option<Arc<Instant>> {
|
||||
self.last_update.load_full()
|
||||
}
|
||||
|
||||
pub fn set_running_mode(&self, mode: RunningMode) {
|
||||
let state = self.state.load();
|
||||
state.running_mode.store(Arc::new(mode));
|
||||
/// The Core is now running in `mode`.
|
||||
///
|
||||
/// Run State derives PAC availability and the outward mode mirror from this; callers must
|
||||
/// not set those alongside.
|
||||
pub fn core_started(&self, mode: RunningMode) {
|
||||
self.run_state.core_started(mode);
|
||||
}
|
||||
|
||||
/// The Core is no longer running, for any reason.
|
||||
///
|
||||
/// Core readiness is invalidated here rather than inside Run State because readiness
|
||||
/// belongs to the process this manager supervises.
|
||||
pub fn core_stopped(&self) {
|
||||
self.invalidate_core_readiness();
|
||||
self.run_state.core_stopped();
|
||||
}
|
||||
|
||||
/// A start attempt is under way and the Core is not serving yet.
|
||||
///
|
||||
/// Must be paired with [`Self::core_start_settled`] on every path out, including the ones
|
||||
/// where the start never happened.
|
||||
pub fn core_starting(&self) {
|
||||
self.run_state.core_starting();
|
||||
}
|
||||
|
||||
/// The start attempt is over: PAC goes back to following the Running Mode.
|
||||
pub fn core_start_settled(&self) {
|
||||
self.run_state.core_start_settled();
|
||||
}
|
||||
|
||||
pub fn set_running_child_sidecar(&self, child: CommandChild) {
|
||||
@@ -115,6 +187,36 @@ impl CoreManager {
|
||||
state.child_sidecar.store(Some(Arc::new(child)));
|
||||
}
|
||||
|
||||
fn mark_core_ready(&self) -> u64 {
|
||||
let mut current = self.core_readiness_state.load(Ordering::Acquire);
|
||||
loop {
|
||||
let next = next_active_core_readiness_state(current);
|
||||
match self
|
||||
.core_readiness_state
|
||||
.compare_exchange(current, next, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
Ok(_) => return next,
|
||||
Err(changed) => current = changed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_core_readiness_generation(&self) -> Option<u64> {
|
||||
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_core_readiness(&self) {
|
||||
let _ = self
|
||||
.core_readiness_state
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
||||
});
|
||||
}
|
||||
|
||||
fn invalidate_core_readiness_if(&self, captured_generation: u64) -> bool {
|
||||
claim_core_readiness_generation(&self.core_readiness_state, captured_generation)
|
||||
}
|
||||
|
||||
pub fn set_last_update(&self, time: Instant) {
|
||||
self.last_update.store(Some(Arc::new(time)));
|
||||
}
|
||||
@@ -130,17 +232,63 @@ impl CoreManager {
|
||||
self.job_handle.store(handle.map(Arc::new));
|
||||
}
|
||||
|
||||
fn try_start_config_update(&self) -> bool {
|
||||
pub(crate) fn try_start_config_update(&self) -> bool {
|
||||
!self.config_update_in_progress.swap(true, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
fn finish_config_update(&self) {
|
||||
pub(crate) fn finish_config_update(&self) {
|
||||
self.config_update_in_progress.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
self.start_core().await?;
|
||||
Ok(())
|
||||
pub async fn init(&self) -> Result<bool> {
|
||||
const MAX_PORT_FALLBACK_RETRIES: usize = 3;
|
||||
|
||||
if let Some(reason) = crate::config::Config::startup_core_block_reason() {
|
||||
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
|
||||
}
|
||||
|
||||
let mut retries = 0;
|
||||
loop {
|
||||
match self.start_core().await {
|
||||
Ok(()) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning));
|
||||
}
|
||||
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
|
||||
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Err(start_error);
|
||||
}
|
||||
match crate::config::Config::retry_startup_mixed_port_fallback().await {
|
||||
Ok(true) => {
|
||||
retries += 1;
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Retrying core startup after mixed proxy port fallback ({}/{})",
|
||||
retries,
|
||||
MAX_PORT_FALLBACK_RETRIES
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Err(start_error);
|
||||
}
|
||||
Err(fallback_error) => {
|
||||
crate::config::Config::block_startup_core(&fallback_error);
|
||||
return Err(anyhow::anyhow!(
|
||||
"core startup failed: {start_error:#}; mixed proxy port fallback failed: \
|
||||
{fallback_error:#}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,77 @@
|
||||
#[cfg(test)]
|
||||
use super::claim_core_readiness_generation;
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::{
|
||||
AsyncHandler,
|
||||
config::{Config, IClashTemp},
|
||||
core::{handle, logger::Logger, manager::CLASH_LOGGER, service},
|
||||
config::Config,
|
||||
core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service},
|
||||
logging,
|
||||
utils::dirs,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::Type;
|
||||
use compact_str::CompactString;
|
||||
use log::Level;
|
||||
use scopeguard::defer;
|
||||
use std::path::Path;
|
||||
use tauri_plugin_mihomo::MihomoExt as _;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||
|
||||
impl CoreManager {
|
||||
/// A core process is up: put back the node selections the user made.
|
||||
///
|
||||
/// mihomo restores these itself, from the `cache.db` it keeps in the directory it was started
|
||||
/// against — but only when `profile.store-selected` is on and that file survived, and neither
|
||||
/// is something the app can assume. The setting comes from a merge template a user is free to
|
||||
/// replace, and a service older than the durable runtime generation hands every start a
|
||||
/// directory nothing has ever run in. The app holds the same selections in the profile, so it
|
||||
/// is the one that can say for certain.
|
||||
///
|
||||
/// Awaited, and deliberately here rather than beside the proxy: the caller enables the system
|
||||
/// proxy as soon as the start returns, and pointing it at a core still on the first entry of
|
||||
/// every group is what this exists to prevent. The wait is bounded — what cannot be put back
|
||||
/// yet keeps being retried in the background — so a core that will not answer delays a start
|
||||
/// instead of blocking it.
|
||||
///
|
||||
/// Repeat calls supersede each other, so every start path may call this without coordinating.
|
||||
async fn restore_selected_nodes(&self) {
|
||||
crate::config::profiles::restore_selected_nodes().await;
|
||||
}
|
||||
}
|
||||
|
||||
const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
|
||||
async fn poll_sidecar_readiness<F, Fut>(
|
||||
max_attempts: usize,
|
||||
retry_delay: std::time::Duration,
|
||||
mut probe: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..max_attempts {
|
||||
match probe().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
if attempt + 1 < max_attempts {
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
Err(last_error
|
||||
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
|
||||
.context("Mihomo API did not become ready"))
|
||||
}
|
||||
|
||||
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
|
||||
matches!(running_mode, RunningMode::Sidecar) && current_pid == Some(terminated_pid)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use {
|
||||
std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
|
||||
@@ -39,33 +98,40 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in sidecar mode");
|
||||
self.core_stopped();
|
||||
|
||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||
handle::Handle::app_handle()
|
||||
.mihomo()
|
||||
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
|
||||
let config_dir = dirs::app_home_dir()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o007) };
|
||||
let (mut rx, child) = app_handle
|
||||
.shell()
|
||||
.sidecar(clash_core.as_str())?
|
||||
.args([
|
||||
"-d",
|
||||
dirs::path_to_str(&config_dir)?,
|
||||
"-f",
|
||||
dirs::path_to_str(&config_file)?,
|
||||
if cfg!(windows) {
|
||||
"-ext-ctl-pipe"
|
||||
} else {
|
||||
"-ext-ctl-unix"
|
||||
},
|
||||
&IClashTemp::guard_external_controller_ipc(),
|
||||
])
|
||||
.spawn()?;
|
||||
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) };
|
||||
let command = app_handle.shell().sidecar(clash_core.as_str())?.args([
|
||||
"-d",
|
||||
dirs::path_to_str(&config_dir)?,
|
||||
"-f",
|
||||
dirs::path_to_str(&config_file)?,
|
||||
if cfg!(windows) {
|
||||
"-ext-ctl-pipe"
|
||||
} else {
|
||||
"-ext-ctl-unix"
|
||||
},
|
||||
dirs::path_to_str(&sidecar_ipc)?,
|
||||
]);
|
||||
#[cfg(windows)]
|
||||
let command = command.env(
|
||||
"LISTEN_NAMEDPIPE_SDDL",
|
||||
crate::core::owner_identity::current_user_pipe_sddl()?,
|
||||
);
|
||||
let (mut rx, child) = command.spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let job = match create_and_assign_sidecar_job(child.pid()) {
|
||||
let job = {
|
||||
match create_and_assign_sidecar_job(child.pid()) {
|
||||
Ok(job) => job,
|
||||
Err(job_error) => {
|
||||
let pid = child.pid();
|
||||
@@ -81,9 +147,8 @@ impl CoreManager {
|
||||
logging!(error, Type::Core, "Failed to start sidecar: {error:#}");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.set_job_handle(Some(job));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
@@ -93,10 +158,34 @@ impl CoreManager {
|
||||
let pid = child.pid();
|
||||
logging!(trace, Type::Core, "Sidecar started with PID: {}", pid);
|
||||
|
||||
self.set_running_child_sidecar(child);
|
||||
self.set_running_mode(RunningMode::Sidecar);
|
||||
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
||||
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
||||
handle::Handle::mihomo().get_version().await
|
||||
})
|
||||
.await
|
||||
.context("Mihomo readiness probe timed out")??;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if let Err(readiness_error) = readiness {
|
||||
proxy_control::stop_guard().await;
|
||||
self.core_stopped();
|
||||
return match child.kill() {
|
||||
Ok(()) => Err(readiness_error),
|
||||
Err(kill_error) => Err(anyhow::anyhow!(
|
||||
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
AsyncHandler::spawn(|| async move {
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(Some(job));
|
||||
self.set_running_child_sidecar(child);
|
||||
let core_readiness_generation = self.mark_core_ready();
|
||||
self.core_started(RunningMode::Sidecar);
|
||||
self.restore_selected_nodes().await;
|
||||
|
||||
AsyncHandler::spawn(move || async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
||||
@@ -106,6 +195,8 @@ impl CoreManager {
|
||||
CLASH_LOGGER.append_log(message).await;
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
|
||||
let manager = Self::global();
|
||||
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
|
||||
let message = if let Some(code) = term.code {
|
||||
CompactString::from(format!("Process terminated with code: {}", code))
|
||||
} else if let Some(signal) = term.signal {
|
||||
@@ -115,6 +206,7 @@ impl CoreManager {
|
||||
};
|
||||
Logger::global().writer_sidecar_log(Level::Info, &message);
|
||||
CLASH_LOGGER.clear_logs().await;
|
||||
manager.clear_terminated_sidecar(pid).await;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
@@ -125,10 +217,12 @@ impl CoreManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn stop_core_by_sidecar(&self) {
|
||||
/// Terminates the sidecar after its caller has successfully cleared the
|
||||
/// system proxy.
|
||||
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
|
||||
logging!(info, Type::Core, "Stopping sidecar");
|
||||
defer! {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
}
|
||||
if let Some(child) = self.take_child_sidecar() {
|
||||
let pid = child.pid();
|
||||
@@ -159,17 +253,28 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in service mode");
|
||||
self.core_starting();
|
||||
let service_ipc = dirs::ipc_path()?;
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
handle::Handle::app_handle()
|
||||
.mihomo()
|
||||
.update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?;
|
||||
|
||||
self.start_core_by_service_with_config(&config_file).await
|
||||
}
|
||||
|
||||
pub(super) async fn start_core_by_service_with_config(&self, config_file: &Path) -> Result<()> {
|
||||
// 交接时等待 sidecar 释放 ext-controller 通道。
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::constants::timing;
|
||||
let mut last_err = None;
|
||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
||||
match service::run_core_by_service(&config_file).await {
|
||||
match service::run_core_by_service(config_file).await {
|
||||
Ok(()) => {
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -191,20 +296,106 @@ impl CoreManager {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
service::run_core_by_service(&config_file).await?;
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
service::run_core_by_service(config_file).await?;
|
||||
self.mark_core_ready();
|
||||
self.core_started(RunningMode::Service);
|
||||
self.restore_selected_nodes().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Stopping service");
|
||||
defer! {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
}
|
||||
service::stop_core_by_service().await?;
|
||||
self.core_stopped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_terminated_sidecar(&self, terminated_pid: u32) {
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
if !should_clear_terminated_sidecar(&self.get_running_mode(), self.get_running_sidecar_pid(), terminated_pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self.take_child_sidecar();
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(None);
|
||||
proxy_control::stop_guard().await;
|
||||
self.core_stopped();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod readiness_tests {
|
||||
use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar};
|
||||
use crate::core::manager::{CoreManager, RunningMode};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let probe_attempts = Arc::clone(&attempts);
|
||||
poll_sidecar_readiness(3, Duration::ZERO, move || {
|
||||
let attempt = probe_attempts.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
async move {
|
||||
if attempt == 3 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("not ready"))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
|
||||
let failed_attempts = Arc::new(AtomicUsize::new(0));
|
||||
let probe_attempts = Arc::clone(&failed_attempts);
|
||||
assert!(
|
||||
poll_sidecar_readiness(3, Duration::ZERO, move || {
|
||||
probe_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err(anyhow::anyhow!("still unavailable")) }
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(failed_attempts.load(Ordering::SeqCst), 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_current_sidecar_termination_clears_local_state() {
|
||||
assert!(should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(42), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(43), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::Service, Some(42), 42));
|
||||
assert!(!should_clear_terminated_sidecar(&RunningMode::NotRunning, None, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_readiness_generation_can_only_be_claimed_once() {
|
||||
let generation = AtomicU64::new(7);
|
||||
|
||||
assert!(claim_core_readiness_generation(&generation, 7));
|
||||
assert_eq!(generation.load(Ordering::Acquire), 8);
|
||||
assert!(!claim_core_readiness_generation(&generation, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() {
|
||||
let manager = CoreManager::isolated();
|
||||
manager.mark_core_ready();
|
||||
manager.core_started(RunningMode::Service);
|
||||
|
||||
manager.invalidate_core_readiness();
|
||||
|
||||
assert_eq!(*manager.get_running_mode(), RunningMode::Service);
|
||||
assert_eq!(manager.current_core_readiness_generation(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -2,9 +2,15 @@ pub mod autostart;
|
||||
pub mod backup;
|
||||
pub mod handle;
|
||||
pub mod hotkey;
|
||||
pub mod listener;
|
||||
pub mod logger;
|
||||
pub mod manager;
|
||||
mod notification;
|
||||
pub(crate) mod owner_identity;
|
||||
pub mod proxy_control;
|
||||
pub mod proxy_view;
|
||||
pub mod runstate;
|
||||
mod runtime_bundle;
|
||||
pub mod service;
|
||||
pub mod sysopt;
|
||||
pub mod timer;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub enum FrontendEvent<'a> {
|
||||
TimerUpdated { profile_index: &'a String },
|
||||
ProfileUpdateStarted { uid: &'a String },
|
||||
ProfileUpdateCompleted { uid: &'a String },
|
||||
RunStateChanged { state: serde_json::Value },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -38,6 +39,7 @@ impl NotificationSystem {
|
||||
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
|
||||
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
|
||||
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
use crate::utils::dirs;
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> {
|
||||
current_owner_credentials_for_root(&dirs::app_home_dir()?)
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)] // Windows SID discovery is fallible; Unix keeps the shared API.
|
||||
pub(crate) fn current_owner_identity() -> Result<OwnerIdentity> {
|
||||
#[cfg(unix)]
|
||||
return Ok(OwnerIdentity::Unix {
|
||||
uid: unsafe { libc::geteuid() },
|
||||
gid: unsafe { libc::getegid() },
|
||||
});
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
windows_current_identity()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result<OwnerCredentials> {
|
||||
let app_data_root = std::fs::canonicalize(app_root)
|
||||
.with_context(|| format!("failed to canonicalize application data root {app_root:?}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let (identity, token) = (current_owner_identity()?, None);
|
||||
|
||||
#[cfg(windows)]
|
||||
let (identity, token) = windows_owner_credentials(&app_data_root)?;
|
||||
|
||||
Ok(OwnerCredentials {
|
||||
identity,
|
||||
app_data_dir: app_data_root.to_string_lossy().into_owned(),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<String>)> {
|
||||
let sid = windows_owner::current_sid()?;
|
||||
let token = windows_owner::load_or_create_token(app_data_root, &sid)?;
|
||||
Ok((OwnerIdentity::Windows { sid }, Some(token)))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_current_identity() -> Result<OwnerIdentity> {
|
||||
Ok(OwnerIdentity::Windows {
|
||||
sid: windows_owner::current_sid()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_private_file(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn current_user_pipe_sddl() -> Result<String> {
|
||||
let sid = windows_owner::current_sid()?;
|
||||
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_owner {
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_service_ipc::OWNER_TOKEN_FILE_NAME;
|
||||
use std::ffi::c_void;
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
|
||||
use std::path::Path;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_FILE_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError, INVALID_HANDLE_VALUE, LocalFree,
|
||||
};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
|
||||
SE_FILE_OBJECT, SetSecurityInfo,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetSecurityDescriptorControl,
|
||||
GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, IsValidSid, IsWellKnownSid,
|
||||
OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
|
||||
SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
|
||||
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, GetFileInformationByHandle, GetFileType,
|
||||
OPEN_EXISTING, READ_CONTROL, WRITE_DAC,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
|
||||
|
||||
pub(super) fn current_sid() -> Result<String> {
|
||||
let mut token = std::ptr::null_mut();
|
||||
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open process token");
|
||||
}
|
||||
let token = OwnedHandle(token);
|
||||
|
||||
let mut required = 0_u32;
|
||||
unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut required) };
|
||||
if required == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to size process SID buffer");
|
||||
}
|
||||
let words = (required as usize).div_ceil(std::mem::size_of::<usize>());
|
||||
let mut buffer = vec![0_usize; words];
|
||||
if unsafe { GetTokenInformation(token.0, TokenUser, buffer.as_mut_ptr().cast(), required, &mut required) } == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read process SID");
|
||||
}
|
||||
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
|
||||
sid_to_string(token_user.User.Sid)
|
||||
}
|
||||
|
||||
pub(super) fn load_or_create_token(app_data_root: &Path, sid: &str) -> Result<String> {
|
||||
let token_path = app_data_root.join(OWNER_TOKEN_FILE_NAME);
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(sid)?;
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let wide = wide_path(&token_path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
&attributes,
|
||||
CREATE_NEW,
|
||||
FILE_ATTRIBUTE_HIDDEN | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
|
||||
if handle != INVALID_HANDLE_VALUE {
|
||||
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
let mut token = [0_u8; TOKEN_BYTES];
|
||||
getrandom::fill(&mut token).context("failed to generate owner token")?;
|
||||
file.write_all(&token).context("failed to write owner token")?;
|
||||
file.sync_all().context("failed to flush owner token")?;
|
||||
return Ok(encode_token(&token));
|
||||
}
|
||||
if unsafe { GetLastError() } != ERROR_FILE_EXISTS {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to create owner token");
|
||||
}
|
||||
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open owner token");
|
||||
}
|
||||
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
validate_token_file(&file, descriptor.owner()?)?;
|
||||
descriptor.apply_dacl(file.as_raw_handle())?;
|
||||
|
||||
let mut token = [0_u8; TOKEN_BYTES];
|
||||
file.read_exact(&mut token).context("failed to read owner token")?;
|
||||
Ok(encode_token(&token))
|
||||
}
|
||||
|
||||
pub(super) fn create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
create_file(path, &descriptor)
|
||||
}
|
||||
|
||||
pub(super) fn open_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
let file = open_file(path)?;
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
match create_file(path, &descriptor) {
|
||||
Ok(file) => Ok(file),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
|
||||
{
|
||||
let file = open_file(path)?;
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let wide = wide_path(path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
&attributes,
|
||||
CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to create private current-user file");
|
||||
}
|
||||
let file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn open_file(path: &Path) -> Result<std::fs::File> {
|
||||
let wide = wide_path(path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open private current-user file");
|
||||
}
|
||||
Ok(unsafe { std::fs::File::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
fn validate_private_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
|
||||
let handle = file.as_raw_handle();
|
||||
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to inspect private file metadata");
|
||||
}
|
||||
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
|
||||
{
|
||||
bail!("current-user file is not an ordinary file");
|
||||
}
|
||||
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut dacl: *mut ACL = std::ptr::null_mut();
|
||||
let mut security = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||||
&mut owner,
|
||||
std::ptr::null_mut(),
|
||||
&mut dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut security,
|
||||
)
|
||||
};
|
||||
if status != 0 || security.is_null() {
|
||||
bail!("failed to inspect private file security: Windows error {status}");
|
||||
}
|
||||
let security = LocalSecurityDescriptor(security);
|
||||
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 || dacl.is_null() {
|
||||
bail!("current-user file owner or DACL is invalid");
|
||||
}
|
||||
let mut control = 0_u16;
|
||||
let mut revision = 0_u32;
|
||||
if unsafe { GetSecurityDescriptorControl(security.0, &mut control, &mut revision) } == 0
|
||||
|| control & SE_DACL_PROTECTED == 0
|
||||
{
|
||||
bail!("current-user file DACL is not protected");
|
||||
}
|
||||
|
||||
let mut owner_ace = false;
|
||||
let mut system_ace = false;
|
||||
let mut administrators_ace = false;
|
||||
for index in 0..unsafe { (*dacl).AceCount } as u32 {
|
||||
let mut ace = std::ptr::null_mut();
|
||||
if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() {
|
||||
bail!("current-user file DACL could not be inspected");
|
||||
}
|
||||
let allowed = unsafe { &*ace.cast::<ACCESS_ALLOWED_ACE>() };
|
||||
if allowed.Header.AceType != ACCESS_ALLOWED_ACE_TYPE || allowed.Mask & FILE_ALL_ACCESS != FILE_ALL_ACCESS {
|
||||
bail!("current-user file DACL contains an unexpected ACE");
|
||||
}
|
||||
let ace_sid = std::ptr::addr_of!(allowed.SidStart).cast_mut().cast::<c_void>();
|
||||
if unsafe { IsValidSid(ace_sid) } == 0 {
|
||||
bail!("current-user file DACL contains an invalid SID");
|
||||
}
|
||||
if unsafe { EqualSid(ace_sid, expected_owner) } != 0 {
|
||||
owner_ace = true;
|
||||
} else if unsafe { IsWellKnownSid(ace_sid, WinLocalSystemSid) } != 0 {
|
||||
system_ace = true;
|
||||
} else if unsafe { IsWellKnownSid(ace_sid, WinBuiltinAdministratorsSid) } != 0 {
|
||||
administrators_ace = true;
|
||||
} else {
|
||||
bail!("current-user file DACL grants access to another SID");
|
||||
}
|
||||
}
|
||||
if unsafe { (*dacl).AceCount } != 3 || !owner_ace || !system_ace || !administrators_ace {
|
||||
bail!("current-user file DACL is missing a required principal");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_token_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
|
||||
let handle = file.as_raw_handle();
|
||||
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to inspect owner token metadata");
|
||||
}
|
||||
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
|
||||
|| information.nFileSizeHigh != 0
|
||||
|| information.nFileSizeLow != TOKEN_BYTES as u32
|
||||
{
|
||||
bail!("owner token is not an ordinary 32-byte file");
|
||||
}
|
||||
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut security = std::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
GetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
OWNER_SECURITY_INFORMATION,
|
||||
&mut owner,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut security,
|
||||
)
|
||||
};
|
||||
if status != 0 || security.is_null() {
|
||||
bail!("failed to inspect owner token security: Windows error {status}");
|
||||
}
|
||||
let security = LocalSecurityDescriptor(security);
|
||||
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 {
|
||||
bail!("owner token belongs to a different Windows user");
|
||||
}
|
||||
drop(security);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sid_to_string(sid: PSID) -> Result<String> {
|
||||
let mut value = std::ptr::null_mut();
|
||||
if unsafe { ConvertSidToStringSidW(sid, &mut value) } == 0 || value.is_null() {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to format process SID");
|
||||
}
|
||||
let value = LocalWideString(value);
|
||||
let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count();
|
||||
String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
|
||||
.context("process SID is not valid UTF-16")
|
||||
}
|
||||
|
||||
fn encode_token(token: &[u8; TOKEN_BYTES]) -> String {
|
||||
let mut encoded = String::with_capacity(TOKEN_BYTES * 2);
|
||||
for byte in token {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(encoded, "{byte:02x}");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn wide_path(path: &Path) -> Result<Vec<u16>> {
|
||||
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
|
||||
if wide.contains(&0) {
|
||||
bail!("owner token path contains NUL");
|
||||
}
|
||||
wide.push(0);
|
||||
Ok(wide)
|
||||
}
|
||||
|
||||
struct OwnedHandle(*mut c_void);
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
|
||||
unsafe { CloseHandle(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalWideString(*mut u16);
|
||||
|
||||
impl Drop for LocalWideString {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe { LocalFree(self.0.cast()) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR);
|
||||
|
||||
impl LocalSecurityDescriptor {
|
||||
fn from_sid(sid: &str) -> Result<Self> {
|
||||
let sddl = format!("O:{sid}D:P(A;;FA;;;{sid})(A;;FA;;;SY)(A;;FA;;;BA)");
|
||||
let mut wide: Vec<u16> = sddl.encode_utf16().collect();
|
||||
wide.push(0);
|
||||
let mut descriptor = std::ptr::null_mut();
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
wide.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut descriptor,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
|| descriptor.is_null()
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to build owner token security descriptor");
|
||||
}
|
||||
Ok(Self(descriptor))
|
||||
}
|
||||
|
||||
fn owner(&self) -> Result<PSID> {
|
||||
let mut owner = std::ptr::null_mut();
|
||||
let mut defaulted = 0;
|
||||
if unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } == 0 || owner.is_null() {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor owner");
|
||||
}
|
||||
Ok(owner)
|
||||
}
|
||||
|
||||
fn apply_dacl(&self, handle: *mut c_void) -> Result<()> {
|
||||
let mut present = 0;
|
||||
let mut defaulted = 0;
|
||||
let mut dacl = std::ptr::null_mut();
|
||||
if unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } == 0
|
||||
|| present == 0
|
||||
|| dacl.is_null()
|
||||
{
|
||||
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor DACL");
|
||||
}
|
||||
let status = unsafe {
|
||||
SetSecurityInfo(
|
||||
handle,
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
dacl,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
bail!("failed to restrict owner token DACL: Windows error {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalSecurityDescriptor {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe { LocalFree(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::current_owner_credentials_for_root;
|
||||
use clash_verge_service_ipc::OwnerIdentity;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_credentials_come_from_process_and_have_no_token() -> anyhow::Result<()> {
|
||||
let app_root = std::env::temp_dir();
|
||||
|
||||
let credentials = current_owner_credentials_for_root(&app_root)?;
|
||||
|
||||
assert_eq!(
|
||||
credentials.identity,
|
||||
OwnerIdentity::Unix {
|
||||
uid: unsafe { libc::geteuid() },
|
||||
gid: unsafe { libc::getegid() },
|
||||
}
|
||||
);
|
||||
assert_eq!(credentials.token, None);
|
||||
assert_eq!(
|
||||
std::path::PathBuf::from(credentials.app_data_dir),
|
||||
std::fs::canonicalize(app_root)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_credentials_use_stable_sid_and_private_token() -> anyhow::Result<()> {
|
||||
let app_root = std::env::temp_dir().join(format!("cvr-owner-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&app_root);
|
||||
std::fs::create_dir(&app_root)?;
|
||||
|
||||
let first = current_owner_credentials_for_root(&app_root)?;
|
||||
let second = current_owner_credentials_for_root(&app_root)?;
|
||||
|
||||
let OwnerIdentity::Windows { sid } = &first.identity else {
|
||||
anyhow::bail!("expected Windows owner identity");
|
||||
};
|
||||
assert!(sid.starts_with("S-1-5-"));
|
||||
assert_eq!(first.identity, second.identity);
|
||||
assert_eq!(first.token, second.token);
|
||||
assert_eq!(first.token.as_deref().map(str::len), Some(64));
|
||||
|
||||
std::fs::remove_dir_all(app_root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn private_file_open_rejects_directories_and_reparse_points() -> anyhow::Result<()> {
|
||||
use super::{create_private_current_user_file, open_private_current_user_file};
|
||||
|
||||
let root = std::env::temp_dir().join(format!("cvr-private-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir(&root)?;
|
||||
|
||||
let private_file = root.join("record.json");
|
||||
drop(create_private_current_user_file(&private_file)?);
|
||||
drop(open_private_current_user_file(&private_file)?);
|
||||
|
||||
let directory = root.join("directory");
|
||||
std::fs::create_dir(&directory)?;
|
||||
assert!(open_private_current_user_file(&directory).is_err());
|
||||
|
||||
let link = root.join("record-link.json");
|
||||
if let Err(error) = std::os::windows::fs::symlink_file(&private_file, &link) {
|
||||
if error.raw_os_error() == Some(1314) {
|
||||
std::fs::remove_dir_all(root)?;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
assert!(open_private_current_user_file(&link).is_err());
|
||||
|
||||
std::fs::remove_file(link)?;
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge, MixedPort},
|
||||
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
|
||||
process::AsyncHandler,
|
||||
utils::server,
|
||||
};
|
||||
use anyhow::{Result, bail, ensure};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
|
||||
use std::{
|
||||
future::Future,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const LOOPBACK_HOST: &str = "127.0.0.1";
|
||||
const MACOS_DEFAULT_BYPASS: &str =
|
||||
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
||||
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
|
||||
|
||||
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
|
||||
|
||||
struct ServiceProxyOperations {
|
||||
guard_generation: AtomicU64,
|
||||
operation_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl ServiceProxyOperations {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
guard_generation: AtomicU64::new(0),
|
||||
operation_lock: Mutex::const_new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_guard(&self) -> u64 {
|
||||
self.guard_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
|
||||
}
|
||||
|
||||
async fn run_service_operation<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let _operation = self.operation_lock.lock().await;
|
||||
operation().await
|
||||
}
|
||||
|
||||
async fn run_final_service_operation<F, Fut, T>(&self, operation: F) -> T
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
self.invalidate_guard();
|
||||
self.run_service_operation(operation).await
|
||||
}
|
||||
|
||||
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
let generation = self.invalidate_guard();
|
||||
self.run_service_operation(cancel).await;
|
||||
generation
|
||||
}
|
||||
|
||||
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
|
||||
&self,
|
||||
captured_generation: u64,
|
||||
captured_proof: &OwnerSessionProof,
|
||||
is_service_mode: Mode,
|
||||
active_proof: ActiveProof,
|
||||
request: Request,
|
||||
) -> Result<bool>
|
||||
where
|
||||
Mode: FnOnce() -> bool,
|
||||
ActiveProof: FnOnce() -> Result<OwnerSessionProof>,
|
||||
Request: FnOnce() -> RequestFuture,
|
||||
RequestFuture: Future<Output = Result<ProxyApplyOutcome>>,
|
||||
{
|
||||
self.run_service_operation(|| async move {
|
||||
if !guard_generation_is_current(&self.guard_generation, captured_generation)
|
||||
|| !is_service_mode()
|
||||
|| !active_proof().is_ok_and(|active| active == *captured_proof)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
service_apply_result(request().await?)?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProxyBackendRoute {
|
||||
Local,
|
||||
Service,
|
||||
}
|
||||
|
||||
const fn proxy_backend_route(is_macos: bool, running_mode: &RunningMode) -> ProxyBackendRoute {
|
||||
if is_macos && matches!(running_mode, RunningMode::Service) {
|
||||
ProxyBackendRoute::Service
|
||||
} else {
|
||||
ProxyBackendRoute::Local
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_generation_is_current(generation: &AtomicU64, captured_generation: u64) -> bool {
|
||||
generation.load(Ordering::Acquire) == captured_generation
|
||||
}
|
||||
|
||||
fn truncate_utf8(value: &mut String, max_bytes: usize) {
|
||||
if value.len() <= max_bytes {
|
||||
return;
|
||||
}
|
||||
let mut boundary = max_bytes;
|
||||
while !value.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
value.truncate(boundary);
|
||||
}
|
||||
|
||||
fn service_bypass(verge: &IVerge) -> Result<String> {
|
||||
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
||||
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
|
||||
|
||||
let mut bypass = if custom.is_empty() {
|
||||
MACOS_DEFAULT_BYPASS.to_owned()
|
||||
} else if verge.use_default_bypass.unwrap_or(true) {
|
||||
format!("{MACOS_DEFAULT_BYPASS},{custom}")
|
||||
} else {
|
||||
custom.to_owned()
|
||||
};
|
||||
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
|
||||
Ok(bypass)
|
||||
}
|
||||
|
||||
fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Result<MacosProxyConfig> {
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
return Ok(MacosProxyConfig::Disabled);
|
||||
}
|
||||
|
||||
if verge.proxy_auto_config.unwrap_or_default() {
|
||||
ensure!(pac_port != 0, "embedded PAC server port must not be zero");
|
||||
Ok(MacosProxyConfig::Pac {
|
||||
url: format!("http://{LOOPBACK_HOST}:{pac_port}/commands/pac"),
|
||||
})
|
||||
} else {
|
||||
ensure!(mixed_port != 0, "system proxy port must not be zero");
|
||||
Ok(MacosProxyConfig::Global {
|
||||
host: LOOPBACK_HOST.to_owned(),
|
||||
port: mixed_port,
|
||||
bypass: service_bypass(verge)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
|
||||
ProxyApplyOutcome::DirectFallback { message } => bail!(message),
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig> {
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
return Ok(MacosProxyConfig::Disabled);
|
||||
}
|
||||
if verge.proxy_auto_config.unwrap_or_default() {
|
||||
return service_proxy_config(verge, 0, server::embedded_server_port()?);
|
||||
}
|
||||
// Configured: this builds the proxy settings handed to the service alongside a start.
|
||||
let mixed_port = MixedPort::desired().await;
|
||||
service_proxy_config(verge, mixed_port, 0)
|
||||
}
|
||||
|
||||
pub async fn apply() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await,
|
||||
ProxyBackendRoute::Service => {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&proxy).await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear() -> Result<()> {
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
|
||||
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await,
|
||||
ProxyBackendRoute::Service => {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.run_final_service_operation(|| async {
|
||||
service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_guard() -> Result<()> {
|
||||
let generation = SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
let running_mode = CoreManager::global().get_running_mode();
|
||||
if matches!(
|
||||
proxy_backend_route(cfg!(target_os = "macos"), &running_mode),
|
||||
ProxyBackendRoute::Local
|
||||
) {
|
||||
Sysopt::global().refresh_guard().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let proxy = current_service_proxy_config(&verge).await?;
|
||||
let proof = service::active_service_session()?;
|
||||
let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1));
|
||||
AsyncHandler::spawn(move || async move {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
match SERVICE_PROXY_OPERATIONS
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&proof,
|
||||
|| matches!(*CoreManager::global().get_running_mode(), RunningMode::Service),
|
||||
service::active_service_session,
|
||||
|| service::set_system_proxy_by_service_with_session(&proxy, &proof),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => break,
|
||||
Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"),
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_guard() {
|
||||
SERVICE_PROXY_OPERATIONS
|
||||
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ProxyBackendRoute, ServiceProxyOperations, guard_generation_is_current, proxy_backend_route,
|
||||
service_proxy_config,
|
||||
};
|
||||
use crate::{config::IVerge, core::manager::RunningMode};
|
||||
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
use std::task::Poll;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
fn proof(generation: u64, token: &str) -> OwnerSessionProof {
|
||||
OwnerSessionProof {
|
||||
generation,
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_macos_service_routes_proxy_to_helper() {
|
||||
assert_eq!(
|
||||
proxy_backend_route(true, &RunningMode::Service),
|
||||
ProxyBackendRoute::Service
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_backend_route(true, &RunningMode::Sidecar),
|
||||
ProxyBackendRoute::Local
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_backend_route(false, &RunningMode::Service),
|
||||
ProxyBackendRoute::Local
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_periodic_refresh_stops_after_owner_loss() {
|
||||
let generation = AtomicU64::new(7);
|
||||
let captured_generation = generation.load(Ordering::Acquire);
|
||||
|
||||
assert!(guard_generation_is_current(&generation, captured_generation));
|
||||
generation.fetch_add(1, Ordering::AcqRel);
|
||||
assert!(!guard_generation_is_current(&generation, captured_generation));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_service_mutation_finishes_after_in_flight_guard_request() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let active_proof = Arc::new(Mutex::new(captured_proof.clone()));
|
||||
let guard_started = Arc::new(Barrier::new(2));
|
||||
let release_guard = Arc::new(Barrier::new(2));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let guard_task = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let captured_proof = captured_proof.clone();
|
||||
let active_proof = Arc::clone(&active_proof);
|
||||
let guard_started = Arc::clone(&guard_started);
|
||||
let release_guard = Arc::clone(&release_guard);
|
||||
let events = Arc::clone(&events);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
events.lock().push("guard-started");
|
||||
guard_started.wait().await;
|
||||
release_guard.wait().await;
|
||||
events.lock().push("guard-finished");
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
};
|
||||
guard_started.wait().await;
|
||||
|
||||
let mut final_operation = Box::pin(operations.run_final_service_operation(|| async {
|
||||
events.lock().push("final");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}));
|
||||
assert!(matches!(futures::poll!(final_operation.as_mut()), Poll::Pending));
|
||||
assert!(!guard_generation_is_current(&operations.guard_generation, generation));
|
||||
release_guard.wait().await;
|
||||
|
||||
assert!(matches!(guard_task.await, Ok(Ok(true))));
|
||||
assert!(matches!(final_operation.await, Ok(())));
|
||||
assert_eq!(&*events.lock(), &["guard-started", "guard-finished", "final"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_invalidation_while_guard_waits_prevents_rpc() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let blocker_started = Arc::new(Barrier::new(2));
|
||||
let release_blocker = Arc::new(Barrier::new(2));
|
||||
|
||||
let blocker = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let blocker_started = Arc::clone(&blocker_started);
|
||||
let release_blocker = Arc::clone(&release_blocker);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_service_operation(|| async {
|
||||
blocker_started.wait().await;
|
||||
release_blocker.wait().await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let rpc_called = AtomicBool::new(false);
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
rpc_called.store(true, Ordering::Release);
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
));
|
||||
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
|
||||
operations.invalidate_guard();
|
||||
release_blocker.wait().await;
|
||||
|
||||
assert!(blocker.await.is_ok());
|
||||
assert!(matches!(guard_request.await, Ok(false)));
|
||||
assert!(!rpc_called.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_guard_loop_does_not_adopt_replacement_proof() {
|
||||
let operations = Arc::new(ServiceProxyOperations::new());
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
let replacement_proof = proof(2, "replacement");
|
||||
let active_proof = Mutex::new(captured_proof.clone());
|
||||
let rpc_called = AtomicBool::new(false);
|
||||
let blocker_started = Arc::new(Barrier::new(2));
|
||||
let release_blocker = Arc::new(Barrier::new(2));
|
||||
|
||||
let blocker = {
|
||||
let operations = Arc::clone(&operations);
|
||||
let blocker_started = Arc::clone(&blocker_started);
|
||||
let release_blocker = Arc::clone(&release_blocker);
|
||||
tokio::spawn(async move {
|
||||
operations
|
||||
.run_service_operation(|| async {
|
||||
blocker_started.wait().await;
|
||||
release_blocker.wait().await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
blocker_started.wait().await;
|
||||
|
||||
let mut guard_request = Box::pin(operations.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(active_proof.lock().clone()),
|
||||
|| async {
|
||||
rpc_called.store(true, Ordering::Release);
|
||||
Ok(ProxyApplyOutcome::Applied)
|
||||
},
|
||||
));
|
||||
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
|
||||
*active_proof.lock() = replacement_proof;
|
||||
release_blocker.wait().await;
|
||||
|
||||
assert!(blocker.await.is_ok());
|
||||
assert!(matches!(guard_request.await, Ok(false)));
|
||||
assert!(!rpc_called.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guard_refresh_rejects_direct_fallback() {
|
||||
let operations = ServiceProxyOperations::new();
|
||||
let generation = operations.invalidate_guard();
|
||||
let captured_proof = proof(1, "captured");
|
||||
|
||||
let refreshed = operations
|
||||
.run_guard_request(
|
||||
generation,
|
||||
&captured_proof,
|
||||
|| true,
|
||||
|| Ok(captured_proof.clone()),
|
||||
|| async {
|
||||
Ok(ProxyApplyOutcome::DirectFallback {
|
||||
message: "fallback detail".to_owned(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(refreshed.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() {
|
||||
let verge = IVerge {
|
||||
enable_system_proxy: Some(true),
|
||||
proxy_auto_config: Some(false),
|
||||
proxy_host: Some("192.0.2.1".into()),
|
||||
system_proxy_bypass: Some(format!("{}界", "x".repeat(8191)).into()),
|
||||
use_default_bypass: Some(false),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
let proxy = service_proxy_config(&verge, 7897, 3333).unwrap_or_else(|_| unreachable!());
|
||||
let MacosProxyConfig::Global { host, port, bypass } = proxy else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 7897);
|
||||
assert!(bypass.len() <= 8192);
|
||||
assert!(bypass.is_char_boundary(bypass.len()));
|
||||
|
||||
let pac_verge = IVerge {
|
||||
proxy_auto_config: Some(true),
|
||||
..verge
|
||||
};
|
||||
assert_eq!(
|
||||
service_proxy_config(&pac_verge, 7897, 3333).unwrap_or_else(|_| unreachable!()),
|
||||
MacosProxyConfig::Pac {
|
||||
url: "http://127.0.0.1:3333/commands/pac".to_owned()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_mihomo::models::{
|
||||
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
||||
};
|
||||
|
||||
pub struct ProxyViewInput {
|
||||
pub runtime_group_order: Vec<String>,
|
||||
pub proxies: Proxies,
|
||||
pub providers: Option<ProxyProviders>,
|
||||
}
|
||||
|
||||
pub struct ProxyViewBuilder;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyViewV1 {
|
||||
pub schema_version: u8,
|
||||
pub order_source: ProxyViewOrderSource,
|
||||
pub provider_state: ProxyViewProviderState,
|
||||
pub global: Option<ProxyGroupView>,
|
||||
pub direct: Option<String>,
|
||||
pub groups: Vec<ProxyGroupView>,
|
||||
pub records: BTreeMap<String, ProxyNodeView>,
|
||||
pub standalone: Vec<String>,
|
||||
pub providers: Vec<ProxyProviderView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProxyViewOrderSource {
|
||||
Runtime,
|
||||
Fallback,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProxyViewProviderState {
|
||||
Ready,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ProxyCapabilities {
|
||||
pub udp: bool,
|
||||
pub xudp: bool,
|
||||
pub tfo: bool,
|
||||
pub mptcp: bool,
|
||||
pub smux: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyGroupView {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub proxy_type: ProxyType,
|
||||
pub alive: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub now: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fixed: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub test_url: Option<String>,
|
||||
pub history: Vec<DelayHistory>,
|
||||
#[serde(flatten)]
|
||||
pub capabilities: ProxyCapabilities,
|
||||
pub members: Vec<ProxyMemberRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyNodeView {
|
||||
pub record_id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub proxy_type: ProxyType,
|
||||
pub alive: bool,
|
||||
pub history: Vec<DelayHistory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub test_url: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub capabilities: ProxyCapabilities,
|
||||
pub source: ProxyNodeSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProxyNodeSource {
|
||||
Core {
|
||||
#[serde(rename = "proxyName")]
|
||||
proxy_name: String,
|
||||
},
|
||||
Provider {
|
||||
#[serde(rename = "providerName")]
|
||||
provider_name: String,
|
||||
#[serde(rename = "proxyName")]
|
||||
proxy_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum ProxyMemberRef {
|
||||
Group {
|
||||
name: String,
|
||||
},
|
||||
Node {
|
||||
name: String,
|
||||
#[serde(rename = "recordId")]
|
||||
record_id: String,
|
||||
},
|
||||
Unresolved {
|
||||
name: String,
|
||||
reason: ProxyMemberUnresolvedReason,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub enum ProxyMemberUnresolvedReason {
|
||||
#[serde(rename = "missing")]
|
||||
Missing,
|
||||
#[serde(rename = "ambiguous")]
|
||||
Ambiguous,
|
||||
#[serde(rename = "provider-unavailable")]
|
||||
ProviderUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyProviderView {
|
||||
pub name: String,
|
||||
pub vehicle_type: ProxyProviderVehicleType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscription_info: Option<ProxySubscriptionInfo>,
|
||||
pub proxy_record_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub enum ProxyProviderVehicleType {
|
||||
#[serde(rename = "HTTP")]
|
||||
Http,
|
||||
#[serde(rename = "File")]
|
||||
File,
|
||||
#[serde(rename = "Inline")]
|
||||
Inline,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ProxySubscriptionInfo {
|
||||
pub upload: i64,
|
||||
pub download: i64,
|
||||
pub total: i64,
|
||||
pub expire: i64,
|
||||
}
|
||||
|
||||
struct MemberResolver<'a> {
|
||||
group_names: BTreeSet<String>,
|
||||
core_node_ids: &'a BTreeMap<String, String>,
|
||||
provider_candidates: &'a BTreeMap<String, Vec<String>>,
|
||||
provider_available: bool,
|
||||
}
|
||||
|
||||
impl MemberResolver<'_> {
|
||||
fn resolve(&self, name: String) -> ProxyMemberRef {
|
||||
if self.group_names.contains(&name) {
|
||||
ProxyMemberRef::Group { name }
|
||||
} else if let Some(record_id) = self.core_node_ids.get(&name) {
|
||||
ProxyMemberRef::Node {
|
||||
name,
|
||||
record_id: record_id.clone(),
|
||||
}
|
||||
} else if !self.provider_available {
|
||||
ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||
}
|
||||
} else {
|
||||
match self.provider_candidates.get(&name).map(Vec::as_slice) {
|
||||
Some([record_id]) => ProxyMemberRef::Node {
|
||||
name,
|
||||
record_id: record_id.clone(),
|
||||
},
|
||||
None => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Missing,
|
||||
},
|
||||
Some(_) => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyViewBuilder {
|
||||
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
|
||||
let ProxyViewInput {
|
||||
runtime_group_order,
|
||||
proxies,
|
||||
providers,
|
||||
} = input;
|
||||
let provider_state = if providers.is_some() {
|
||||
ProxyViewProviderState::Ready
|
||||
} else {
|
||||
ProxyViewProviderState::Unavailable
|
||||
};
|
||||
let (mut core_groups, core_nodes) = partition_core(proxies);
|
||||
let (mut records, core_node_ids) = build_core_records(core_nodes);
|
||||
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
|
||||
let resolver = MemberResolver {
|
||||
group_names: core_groups.keys().cloned().collect(),
|
||||
core_node_ids: &core_node_ids,
|
||||
provider_candidates: &provider_candidates,
|
||||
provider_available: provider_state == ProxyViewProviderState::Ready,
|
||||
};
|
||||
|
||||
let global = core_groups
|
||||
.remove("GLOBAL")
|
||||
.map(|proxy| build_group("GLOBAL".to_owned(), proxy, &resolver));
|
||||
let (groups, order_source) = build_ordered_groups(core_groups, &runtime_group_order, &resolver);
|
||||
let direct = core_node_ids.get("DIRECT").cloned();
|
||||
let standalone = build_standalone(&core_node_ids);
|
||||
|
||||
ProxyViewV1 {
|
||||
schema_version: 1,
|
||||
order_source,
|
||||
provider_state,
|
||||
global,
|
||||
direct,
|
||||
groups,
|
||||
records,
|
||||
standalone,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
||||
let mut groups = BTreeMap::new();
|
||||
let mut nodes = BTreeMap::new();
|
||||
|
||||
for (name, proxy) in proxies.proxies {
|
||||
if proxy.all.is_some() {
|
||||
groups.insert(name, proxy);
|
||||
} else {
|
||||
nodes.insert(name, proxy);
|
||||
}
|
||||
}
|
||||
|
||||
(groups, nodes)
|
||||
}
|
||||
|
||||
fn build_core_records(
|
||||
core_nodes: BTreeMap<String, Proxy>,
|
||||
) -> (BTreeMap<String, ProxyNodeView>, BTreeMap<String, String>) {
|
||||
let mut records = BTreeMap::new();
|
||||
let mut ids = BTreeMap::new();
|
||||
|
||||
for (index, (name, proxy)) in core_nodes.into_iter().enumerate() {
|
||||
let record_id = format!("c:{index}");
|
||||
ids.insert(name.clone(), record_id.clone());
|
||||
records.insert(
|
||||
record_id.clone(),
|
||||
build_node(
|
||||
record_id,
|
||||
name.clone(),
|
||||
proxy,
|
||||
ProxyNodeSource::Core { proxy_name: name },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(records, ids)
|
||||
}
|
||||
|
||||
fn build_provider_records(
|
||||
providers: Option<ProxyProviders>,
|
||||
records: &mut BTreeMap<String, ProxyNodeView>,
|
||||
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
|
||||
let providers = providers
|
||||
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
||||
.unwrap_or_default();
|
||||
let mut views = Vec::new();
|
||||
let mut candidates = BTreeMap::new();
|
||||
|
||||
for (provider_name, provider) in providers {
|
||||
let ProxyProvider {
|
||||
vehicle_type,
|
||||
proxies,
|
||||
updated_at,
|
||||
subscription_info,
|
||||
..
|
||||
} = provider;
|
||||
let vehicle_type = match vehicle_type {
|
||||
VehicleType::HTTP => ProxyProviderVehicleType::Http,
|
||||
VehicleType::File => ProxyProviderVehicleType::File,
|
||||
VehicleType::Inline => ProxyProviderVehicleType::Inline,
|
||||
_ => continue,
|
||||
};
|
||||
let provider_index = views.len();
|
||||
let mut proxy_record_ids = Vec::new();
|
||||
|
||||
for (member_index, proxy) in proxies.into_iter().enumerate() {
|
||||
let record_id = format!("p:{provider_index}:{member_index}");
|
||||
let proxy_name = proxy.name.clone();
|
||||
let node_name = proxy.name.clone();
|
||||
candidates
|
||||
.entry(proxy_name.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(record_id.clone());
|
||||
records.insert(
|
||||
record_id.clone(),
|
||||
build_node(
|
||||
record_id.clone(),
|
||||
node_name,
|
||||
proxy,
|
||||
ProxyNodeSource::Provider {
|
||||
provider_name: provider_name.clone(),
|
||||
proxy_name,
|
||||
},
|
||||
),
|
||||
);
|
||||
proxy_record_ids.push(record_id);
|
||||
}
|
||||
|
||||
views.push(ProxyProviderView {
|
||||
name: provider_name,
|
||||
vehicle_type,
|
||||
updated_at,
|
||||
subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo {
|
||||
upload: subscription_info.upload,
|
||||
download: subscription_info.download,
|
||||
total: subscription_info.total,
|
||||
expire: subscription_info.expire,
|
||||
}),
|
||||
proxy_record_ids,
|
||||
});
|
||||
}
|
||||
|
||||
(views, candidates)
|
||||
}
|
||||
|
||||
fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> ProxyGroupView {
|
||||
let Proxy {
|
||||
all,
|
||||
fixed,
|
||||
hidden,
|
||||
icon,
|
||||
now,
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
mptcp,
|
||||
smux,
|
||||
proxy_type,
|
||||
..
|
||||
} = proxy;
|
||||
|
||||
ProxyGroupView {
|
||||
name,
|
||||
proxy_type,
|
||||
alive,
|
||||
now,
|
||||
fixed,
|
||||
hidden,
|
||||
icon,
|
||||
test_url,
|
||||
history,
|
||||
capabilities: ProxyCapabilities {
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
mptcp,
|
||||
smux,
|
||||
},
|
||||
members: all
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|name| resolver.resolve(name))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSource) -> ProxyNodeView {
|
||||
let Proxy {
|
||||
id,
|
||||
hidden,
|
||||
icon,
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
mptcp,
|
||||
smux,
|
||||
proxy_type,
|
||||
..
|
||||
} = proxy;
|
||||
|
||||
ProxyNodeView {
|
||||
record_id,
|
||||
name,
|
||||
proxy_type,
|
||||
alive,
|
||||
history,
|
||||
id,
|
||||
hidden,
|
||||
icon,
|
||||
test_url,
|
||||
capabilities: ProxyCapabilities {
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
mptcp,
|
||||
smux,
|
||||
},
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ordered_groups(
|
||||
mut core_groups: BTreeMap<String, Proxy>,
|
||||
runtime_group_order: &[String],
|
||||
resolver: &MemberResolver<'_>,
|
||||
) -> (Vec<ProxyGroupView>, ProxyViewOrderSource) {
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut names = Vec::new();
|
||||
for name in runtime_group_order {
|
||||
if core_groups.contains_key(name) && selected.insert(name.clone()) {
|
||||
names.push(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let order_source = if names.is_empty() {
|
||||
ProxyViewOrderSource::Fallback
|
||||
} else {
|
||||
ProxyViewOrderSource::Runtime
|
||||
};
|
||||
names.extend(core_groups.keys().filter(|name| !selected.contains(*name)).cloned());
|
||||
|
||||
let groups = names
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
core_groups
|
||||
.remove(&name)
|
||||
.map(|proxy| build_group(name, proxy, resolver))
|
||||
})
|
||||
.collect();
|
||||
(groups, order_source)
|
||||
}
|
||||
|
||||
fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
|
||||
let mut standalone = ["DIRECT", "REJECT"]
|
||||
.into_iter()
|
||||
.filter_map(|name| core_node_ids.get(name).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
standalone.extend(
|
||||
core_node_ids
|
||||
.iter()
|
||||
.filter(|(name, _)| name.as_str() != "DIRECT" && name.as_str() != "REJECT")
|
||||
.map(|(_, record_id)| record_id.clone()),
|
||||
);
|
||||
standalone
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri_plugin_mihomo::models::{
|
||||
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
|
||||
ProxyViewOrderSource, ProxyViewProviderState,
|
||||
};
|
||||
|
||||
fn node(name: &str) -> Proxy {
|
||||
Proxy {
|
||||
name: name.to_owned(),
|
||||
proxy_type: ProxyType::Shadowsocks,
|
||||
alive: true,
|
||||
udp: true,
|
||||
..Proxy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn group(name: &str, members: &[&str]) -> Proxy {
|
||||
Proxy {
|
||||
name: format!("ignored-{name}"),
|
||||
proxy_type: ProxyType::Selector,
|
||||
alive: true,
|
||||
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
|
||||
..Proxy::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
|
||||
let entries = [
|
||||
("Zulu", group("Zulu", &["node-b", "node-b"])),
|
||||
("Alpha", group("Alpha", &["node-a"])),
|
||||
("node-b", node("wrong-node-b")),
|
||||
("node-a", node("wrong-node-a")),
|
||||
("A-before-direct", node("wrong-before-direct")),
|
||||
("DIRECT", node("wrong-direct")),
|
||||
("REJECT", node("wrong-reject")),
|
||||
];
|
||||
let mut proxies = HashMap::new();
|
||||
if reverse_insert {
|
||||
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
|
||||
} else {
|
||||
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
|
||||
}
|
||||
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
|
||||
proxies: Proxies { proxies },
|
||||
providers: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
|
||||
let providers = entries
|
||||
.into_iter()
|
||||
.map(|(provider_key, vehicle_type, names)| {
|
||||
let provider = ProxyProvider {
|
||||
name: format!("ignored-{provider_key}"),
|
||||
vehicle_type,
|
||||
proxies: names.iter().map(|name| node(name)).collect(),
|
||||
..ProxyProvider::default()
|
||||
};
|
||||
(provider_key.to_owned(), provider)
|
||||
})
|
||||
.collect();
|
||||
|
||||
ProxyProviders { providers }
|
||||
}
|
||||
|
||||
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
|
||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers: Some(providers_fixture(vec![
|
||||
("a", VehicleType::HTTP, &["duplicate"]),
|
||||
("b", VehicleType::File, &["duplicate"]),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
|
||||
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_with_provider_duplicates() -> ProxyViewInput {
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(providers_fixture(vec![
|
||||
("z-provider", VehicleType::HTTP, &["z-node"]),
|
||||
("unsupported", VehicleType::Compatible, &["ignored"]),
|
||||
("a-provider", VehicleType::File, &["same", "same"]),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
fn serde_input_with_provider() -> ProxyViewInput {
|
||||
let provider = ProxyProvider {
|
||||
name: "ignored-object-name".to_owned(),
|
||||
vehicle_type: VehicleType::HTTP,
|
||||
updated_at: None,
|
||||
subscription_info: Some(SubScriptionInfo {
|
||||
upload: 1,
|
||||
download: 2,
|
||||
total: 3,
|
||||
expire: 4,
|
||||
}),
|
||||
..ProxyProvider::default()
|
||||
};
|
||||
|
||||
ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(ProxyProviders {
|
||||
providers: HashMap::from([("provider-key".to_owned(), provider)]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_and_core_records_are_deterministic() {
|
||||
let first = ProxyViewBuilder::build(ordered_input(false));
|
||||
let second = ProxyViewBuilder::build(ordered_input(true));
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
|
||||
assert_eq!(
|
||||
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Zulu", "Alpha"]
|
||||
);
|
||||
assert_eq!(first.direct.as_deref(), Some("c:1"));
|
||||
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
|
||||
assert_eq!(first.records["c:0"].name, "A-before-direct");
|
||||
assert_eq!(first.records["c:1"].name, "DIRECT");
|
||||
assert_eq!(first.records["c:4"].name, "node-b");
|
||||
assert_eq!(first.groups[0].members.len(), 2);
|
||||
assert!(matches!(
|
||||
&first.groups[0].members[0],
|
||||
ProxyMemberRef::Node { name, .. } if name == "node-b"
|
||||
));
|
||||
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_and_unmatched_groups_are_stable() {
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec![];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "Zulu"]
|
||||
);
|
||||
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec!["Missing".into()];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Alpha", "Zulu"]
|
||||
);
|
||||
|
||||
let mut input = ordered_input(false);
|
||||
input.runtime_group_order = vec!["Zulu".into()];
|
||||
let view = ProxyViewBuilder::build(input);
|
||||
assert_eq!(
|
||||
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
|
||||
["Zulu", "Alpha"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_and_member_resolution_follow_the_required_priority() {
|
||||
let mut proxies = HashMap::new();
|
||||
proxies.insert(
|
||||
"GLOBAL".into(),
|
||||
group("wrong", &["Nested", "core", "provider-only", "missing"]),
|
||||
);
|
||||
proxies.insert("Nested".into(), group("wrong", &[]));
|
||||
proxies.insert("core".into(), node("wrong"));
|
||||
let providers = providers_fixture(vec![
|
||||
(
|
||||
"a",
|
||||
VehicleType::HTTP,
|
||||
&["Nested", "core", "provider-only", "duplicate"],
|
||||
),
|
||||
("b", VehicleType::File, &["duplicate"]),
|
||||
]);
|
||||
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies { proxies },
|
||||
providers: Some(providers),
|
||||
});
|
||||
let members = &view.global.as_ref().expect("GLOBAL").members;
|
||||
|
||||
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
|
||||
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
|
||||
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
|
||||
assert!(matches!(
|
||||
members[3],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::Missing,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
|
||||
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
|
||||
assert!(matches!(
|
||||
ready.groups[0].members[0],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
|
||||
assert!(matches!(
|
||||
unavailable.groups[0].members[0],
|
||||
ProxyMemberRef::Unresolved {
|
||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
|
||||
assert!(unavailable.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn providers_preserve_identity_duplicates_and_member_order() {
|
||||
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
|
||||
assert_eq!(
|
||||
view.providers
|
||||
.iter()
|
||||
.map(|provider| provider.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["a-provider", "z-provider"]
|
||||
);
|
||||
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
|
||||
assert_eq!(view.records["p:0:0"].name, "same");
|
||||
assert_eq!(view.records["p:0:1"].name, "same");
|
||||
assert_eq!(
|
||||
view.records["p:0:0"].source,
|
||||
ProxyNodeSource::Provider {
|
||||
provider_name: "a-provider".into(),
|
||||
proxy_name: "same".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
|
||||
let view = ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order: vec![],
|
||||
proxies: Proxies::default(),
|
||||
providers: Some(providers_fixture(vec![(
|
||||
"unsupported",
|
||||
VehicleType::Compatible,
|
||||
&["ignored"],
|
||||
)])),
|
||||
});
|
||||
|
||||
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
|
||||
assert!(view.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_matches_the_v1_wire_contract() {
|
||||
let view = ProxyViewBuilder::build(serde_input_with_provider());
|
||||
let json = serde_json::to_value(view).expect("serialize view");
|
||||
|
||||
assert_eq!(json["schemaVersion"], 1);
|
||||
assert_eq!(json["orderSource"], "fallback");
|
||||
assert_eq!(json["providerState"], "ready");
|
||||
assert!(json["global"].is_null());
|
||||
assert!(json["direct"].is_null());
|
||||
assert!(json["providers"][0].get("vehicleType").is_some());
|
||||
assert!(json["providers"][0].get("updatedAt").is_none());
|
||||
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
|
||||
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The seam between Run State and the machine it observes.
|
||||
//!
|
||||
//! Two adapters satisfy it: [`RealEnv`] talks to the Service over IPC and to the platform's
|
||||
//! service registry, [`FakeEnv`] replays scripted answers. Because the store is generic over
|
||||
//! this trait rather than reaching for globals, the state machine can be exercised without
|
||||
//! a running app, an installed Service, or elevation.
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use super::health::{PendingAction, RunState};
|
||||
use super::probe::ServiceVersionReply;
|
||||
|
||||
/// Everything Run State needs from outside itself.
|
||||
pub trait RunStateEnv: Send + Sync + 'static {
|
||||
/// Ask the Service which protocol it speaks. Fails when it cannot be reached at all.
|
||||
fn probe_service_version(&self) -> impl Future<Output = Result<ServiceVersionReply>> + Send;
|
||||
|
||||
/// Whether the platform's service registry has a trusted record of an installation.
|
||||
///
|
||||
/// Fails when the registry itself could not be inspected, which is different from
|
||||
/// "no installation" and is treated as unavailable rather than not-installed.
|
||||
///
|
||||
/// Async so blocking systemd and Windows SCM checks can run off the runtime workers.
|
||||
fn trusted_install_evidence(&self) -> impl Future<Output = Result<bool>> + Send;
|
||||
|
||||
/// Whether this app process is running elevated.
|
||||
fn is_elevated(&self) -> bool;
|
||||
|
||||
/// Open or close the local PAC endpoint.
|
||||
///
|
||||
/// Derived from the Running Mode, never set independently: a PAC script that points at a
|
||||
/// proxy port nothing is listening on is worse than no PAC at all.
|
||||
fn set_pac_available(&self, available: bool);
|
||||
|
||||
/// Publish the Run State to observers outside the store.
|
||||
///
|
||||
/// Two of them today: the diagnostic snapshot, which reports the Running Mode, and the
|
||||
/// frontend, which gets the whole thing pushed rather than polling for it.
|
||||
fn publish(&self, state: &RunState);
|
||||
|
||||
/// Carry out a privileged operation against the Service.
|
||||
///
|
||||
/// Blocking and platform-specific — SCM, systemd, launchd, elevation prompts — which is
|
||||
/// exactly why it sits behind the seam: the state machine around it can then be exercised
|
||||
/// without installing anything.
|
||||
fn run_privileged(&self, action: PendingAction) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The production adapter: real IPC, real platform registry, real elevation check.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct RealEnv;
|
||||
|
||||
impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
let response = clash_verge_service_ipc::get_version().await?;
|
||||
Ok(ServiceVersionReply {
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
protocol: response.data,
|
||||
})
|
||||
}
|
||||
|
||||
async fn trusted_install_evidence(&self) -> Result<bool> {
|
||||
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
|
||||
.await
|
||||
.context("service registration probe did not finish")?
|
||||
}
|
||||
|
||||
fn is_elevated(&self) -> bool {
|
||||
// Deliberately non-panicking, unlike `Handle::app_handle()`: Run State is read from
|
||||
// startup paths that run before the app handle exists, and "not yet known" must
|
||||
// degrade to "not elevated" rather than abort the process.
|
||||
crate::APP_HANDLE
|
||||
.get()
|
||||
.is_some_and(tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin)
|
||||
}
|
||||
|
||||
fn set_pac_available(&self, available: bool) {
|
||||
crate::utils::server::set_pac_available(available);
|
||||
}
|
||||
|
||||
fn publish(&self, state: &RunState) {
|
||||
// Reconciling capability is a reaction to the new state, not part of publishing it, so
|
||||
// it is spawned: a transition must not wait on a config write it triggered. It is also
|
||||
// spawned *before* the app-handle gate below, because a Core that stops and starts
|
||||
// before the app handle exists still needs reconciling; the task re-reads the Run
|
||||
// State itself rather than closing over this snapshot.
|
||||
crate::process::AsyncHandler::spawn(|| async {
|
||||
crate::feat::reconcile_tun_availability().await;
|
||||
});
|
||||
|
||||
// Non-panicking for the same reason as `is_elevated`: the Core can stop and start
|
||||
// before the app handle exists, and a missed notification is better than an abort.
|
||||
let Some(app_handle) = crate::APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, state.mode.to_string());
|
||||
crate::core::handle::Handle::notify_run_state(&state.to_view());
|
||||
}
|
||||
|
||||
fn run_privileged(&self, action: PendingAction) -> Result<()> {
|
||||
crate::core::service::run_privileged_service_action(action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub use fake::FakeEnv;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fake {
|
||||
use anyhow::{Result, anyhow};
|
||||
use clash_verge_service_ipc::ProtocolInfo;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply};
|
||||
use crate::core::manager::RunningMode;
|
||||
|
||||
/// A scripted stand-in for the machine.
|
||||
///
|
||||
/// Defaults to the least capable environment — no Service, no elevation — so a test only
|
||||
/// has to say what it needs. Outbound effects are recorded rather than performed.
|
||||
#[derive(Debug)]
|
||||
pub struct FakeEnv {
|
||||
version_replies: Mutex<Vec<Result<ServiceVersionReply, String>>>,
|
||||
evidence: Result<bool, String>,
|
||||
elevated: bool,
|
||||
probe_count: Mutex<usize>,
|
||||
pac_available: Mutex<Option<bool>>,
|
||||
published: Mutex<Vec<RunState>>,
|
||||
privileged_outcome: Mutex<Result<(), String>>,
|
||||
privileged_actions: Mutex<Vec<PendingAction>>,
|
||||
}
|
||||
|
||||
impl Default for FakeEnv {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version_replies: Mutex::new(Vec::new()),
|
||||
evidence: Ok(false),
|
||||
elevated: false,
|
||||
probe_count: Mutex::new(0),
|
||||
pac_available: Mutex::new(None),
|
||||
published: Mutex::new(Vec::new()),
|
||||
privileged_outcome: Mutex::new(Ok(())),
|
||||
privileged_actions: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeEnv {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// An installed, reachable, protocol-compatible Service.
|
||||
#[must_use]
|
||||
pub fn service_ready(self) -> Self {
|
||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// An installed Service that answers with an incompatible protocol.
|
||||
#[must_use]
|
||||
pub fn service_version_mismatch(self) -> Self {
|
||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Registered with the platform but not answering.
|
||||
#[must_use]
|
||||
pub fn service_unreachable(self) -> Self {
|
||||
self.with_evidence(true)
|
||||
.always_replying(Err("ipc transport refused".to_owned()))
|
||||
}
|
||||
|
||||
/// The platform registry itself could not be inspected.
|
||||
#[must_use]
|
||||
pub fn evidence_unavailable(mut self) -> Self {
|
||||
self.evidence = Err("registry probe failed".to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn elevated(mut self) -> Self {
|
||||
self.elevated = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_evidence(mut self, exists: bool) -> Self {
|
||||
self.evidence = Ok(exists);
|
||||
self
|
||||
}
|
||||
|
||||
/// Queue replies consumed one per probe; the last one repeats once exhausted.
|
||||
#[must_use]
|
||||
pub fn replying(self, replies: Vec<Result<ServiceVersionReply, String>>) -> Self {
|
||||
*self.version_replies.lock() = replies;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn always_replying(self, reply: Result<ServiceVersionReply, String>) -> Self {
|
||||
self.replying(vec![reply])
|
||||
}
|
||||
|
||||
/// How many times the Service was probed — asserts that retries actually retried.
|
||||
#[must_use]
|
||||
pub fn probe_count(&self) -> usize {
|
||||
*self.probe_count.lock()
|
||||
}
|
||||
|
||||
/// The last PAC availability the store asked for, or `None` if it never asked.
|
||||
#[must_use]
|
||||
pub fn pac_available(&self) -> Option<bool> {
|
||||
*self.pac_available.lock()
|
||||
}
|
||||
|
||||
/// Every Running Mode published outward, in order.
|
||||
#[must_use]
|
||||
pub fn published_modes(&self) -> Vec<RunningMode> {
|
||||
self.published.lock().iter().map(|state| state.mode).collect()
|
||||
}
|
||||
|
||||
/// Every Run State published outward, in order.
|
||||
#[must_use]
|
||||
pub fn published(&self) -> Vec<RunState> {
|
||||
self.published.lock().clone()
|
||||
}
|
||||
|
||||
/// Make every privileged operation fail with `reason`.
|
||||
#[must_use]
|
||||
pub fn privileged_operations_fail(self, reason: &str) -> Self {
|
||||
*self.privileged_outcome.lock() = Err(reason.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// Every privileged operation actually attempted, in order.
|
||||
#[must_use]
|
||||
pub fn privileged_actions(&self) -> Vec<PendingAction> {
|
||||
self.privileged_actions.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl RunStateEnv for FakeEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
*self.probe_count.lock() += 1;
|
||||
let mut replies = self.version_replies.lock();
|
||||
let reply = if replies.len() > 1 {
|
||||
replies.remove(0)
|
||||
} else {
|
||||
replies
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Err("no service configured".to_owned()))
|
||||
};
|
||||
reply.map_err(|error| anyhow!(error))
|
||||
}
|
||||
|
||||
async fn trusted_install_evidence(&self) -> Result<bool> {
|
||||
self.evidence.clone().map_err(|error| anyhow!(error))
|
||||
}
|
||||
|
||||
fn is_elevated(&self) -> bool {
|
||||
self.elevated
|
||||
}
|
||||
|
||||
fn set_pac_available(&self, available: bool) {
|
||||
*self.pac_available.lock() = Some(available);
|
||||
}
|
||||
|
||||
fn publish(&self, state: &RunState) {
|
||||
self.published.lock().push(state.clone());
|
||||
}
|
||||
|
||||
fn run_privileged(&self, action: PendingAction) -> Result<()> {
|
||||
self.privileged_actions.lock().push(action);
|
||||
self.privileged_outcome.lock().clone().map_err(|error| anyhow!(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Run State value types.
|
||||
//!
|
||||
//! The central distinction: [`ServiceHealth`] is what we *observed* about the Service,
|
||||
//! [`PendingAction`] is what this session *asked for*. The legacy `ServiceStatus` enum
|
||||
//! conflated the two into one slot, which is why narrowing it for the frontend had to be
|
||||
//! lossy. Nothing narrows now: [`RunStateView`] carries both, plus the derived answers.
|
||||
//!
|
||||
//! This module owns state only. Starting, stopping and restarting the Core stay in
|
||||
//! `CoreManager`: a module that both observes Service Health and drives the Core has no point
|
||||
//! at which it is consistent, because a snapshot taken mid-restart would describe a transition
|
||||
//! rather than a state.
|
||||
|
||||
use crate::core::manager::RunningMode;
|
||||
|
||||
/// What was last observed about the Service. A fact about the machine, never a request.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum ServiceHealth {
|
||||
/// Not probed yet.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// Installed, reachable, and speaking a compatible protocol.
|
||||
Ready,
|
||||
/// No trusted installation evidence on this machine.
|
||||
NotInstalled,
|
||||
/// Installed but speaking an incompatible protocol; needs reinstall.
|
||||
VersionMismatch,
|
||||
/// Installed but unusable, with the reason we recorded.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
impl ServiceHealth {
|
||||
/// The variant name alone, for callers that carry the reason separately.
|
||||
const fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Ready => "ready",
|
||||
Self::NotInstalled => "notInstalled",
|
||||
Self::VersionMismatch => "versionMismatch",
|
||||
Self::Unavailable(_) => "unavailable",
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Unavailable(reason) => Some(reason.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A privileged operation this session asked for against the Service.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PendingAction {
|
||||
Install,
|
||||
Uninstall,
|
||||
Reinstall,
|
||||
ForceReinstall,
|
||||
}
|
||||
|
||||
/// The single consistent answer to "how is the Core running, and what backs it".
|
||||
///
|
||||
/// Every question about Core availability is answered by a method here, so that no
|
||||
/// caller writes its own formula. Construct one via `RunStateStore::state`/`settled`/`probe`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RunState {
|
||||
pub health: ServiceHealth,
|
||||
pub pending: Option<PendingAction>,
|
||||
pub sidecar_allowed: bool,
|
||||
pub mode: RunningMode,
|
||||
pub is_admin: bool,
|
||||
/// A privileged operation is currently in flight, so `health` may be stale.
|
||||
pub op_in_flight: bool,
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
/// The Service reported itself ready the last time we looked.
|
||||
///
|
||||
/// Says nothing about in-flight operations — prefer [`Self::service_usable`] when
|
||||
/// deciding whether to rely on the Service right now.
|
||||
#[must_use]
|
||||
pub const fn service_ready(&self) -> bool {
|
||||
matches!(self.health, ServiceHealth::Ready)
|
||||
}
|
||||
|
||||
/// The Service can be relied upon right now: ready, and not mid-operation.
|
||||
#[must_use]
|
||||
pub const fn service_usable(&self) -> bool {
|
||||
self.service_ready() && !self.op_in_flight
|
||||
}
|
||||
|
||||
/// TUN mode can work right now — either the app is elevated, or the Service backs it.
|
||||
#[must_use]
|
||||
pub const fn tun_capable(&self) -> bool {
|
||||
self.is_admin || self.service_usable()
|
||||
}
|
||||
|
||||
/// The Service cannot be used and the user has to choose what to do about it.
|
||||
///
|
||||
/// A requested action always needs an answer — unless it is already being carried out,
|
||||
/// which is a report of progress rather than a question. The legacy status could not tell
|
||||
/// the two apart because readers blocked until the operation finished and so never saw the
|
||||
/// request at all; now that every transition is pushed, an install started from the
|
||||
/// settings page would otherwise raise the migration dialog on top of it, and an uninstall
|
||||
/// would raise one offering to reinstall the very Service being removed.
|
||||
///
|
||||
/// Otherwise it is only the states where the Service is present but unusable: a Service
|
||||
/// that is simply absent, or a session that has already settled on Sidecar, needs nothing
|
||||
/// from anyone.
|
||||
#[must_use]
|
||||
pub const fn service_needs_attention(&self) -> bool {
|
||||
if self.op_in_flight {
|
||||
return false;
|
||||
}
|
||||
if self.pending.is_some() {
|
||||
return true;
|
||||
}
|
||||
if self.sidecar_allowed {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
self.health,
|
||||
ServiceHealth::VersionMismatch | ServiceHealth::Unavailable(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// TUN is switched on but cannot work, and we know that for certain.
|
||||
///
|
||||
/// "For certain" is the whole difficulty: an unprobed Service, an operation in flight, or a
|
||||
/// decision still sitting in front of the user all mean *not yet known*, and turning TUN
|
||||
/// off on a guess would silently undo something the user asked for. This replaces the fixed
|
||||
/// startup grace period the frontend used to wait out, which guessed at the same thing.
|
||||
#[must_use]
|
||||
pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
|
||||
if !tun_enabled || self.tun_capable() {
|
||||
return false;
|
||||
}
|
||||
!matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention()
|
||||
}
|
||||
|
||||
/// The shape sent across the IPC seam.
|
||||
///
|
||||
/// Carries the derived answers, not just the raw fields, so that no caller on the other
|
||||
/// side reinvents `tun_capable` or the "needs attention" ladder.
|
||||
#[must_use]
|
||||
pub fn to_view(&self) -> RunStateView {
|
||||
RunStateView {
|
||||
mode: self.mode,
|
||||
service: self.health.kind(),
|
||||
service_unavailable_reason: self.health.reason(),
|
||||
pending_action: self.pending,
|
||||
sidecar_allowed: self.sidecar_allowed,
|
||||
is_admin: self.is_admin,
|
||||
op_in_flight: self.op_in_flight,
|
||||
service_usable: self.service_usable(),
|
||||
tun_capable: self.tun_capable(),
|
||||
service_needs_attention: self.service_needs_attention(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Run State as the frontend sees it.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunStateView {
|
||||
pub mode: RunningMode,
|
||||
pub service: &'static str,
|
||||
pub service_unavailable_reason: Option<String>,
|
||||
pub pending_action: Option<PendingAction>,
|
||||
pub sidecar_allowed: bool,
|
||||
pub is_admin: bool,
|
||||
pub op_in_flight: bool,
|
||||
pub service_usable: bool,
|
||||
pub tun_capable: bool,
|
||||
pub service_needs_attention: bool,
|
||||
}
|
||||
|
||||
/// The service-owned part of the Run State, stored behind one lock.
|
||||
///
|
||||
/// At most one of `pending` / `sidecar_allowed` is set at a time; the transitions in
|
||||
/// [`super::RunStateStore`] maintain that, mirroring the single-slot semantics the legacy
|
||||
/// `ServiceStatus` had.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(super) struct StoredService {
|
||||
pub health: ServiceHealth,
|
||||
pub pending: Option<PendingAction>,
|
||||
pub sidecar_allowed: bool,
|
||||
}
|
||||
|
||||
impl StoredService {
|
||||
/// Record an observation, clearing any request it answers.
|
||||
pub fn observe(&mut self, health: ServiceHealth) {
|
||||
self.health = health;
|
||||
self.pending = None;
|
||||
self.sidecar_allowed = false;
|
||||
}
|
||||
|
||||
/// Record a requested privileged operation, preserving the last observation.
|
||||
pub const fn request(&mut self, action: PendingAction) {
|
||||
self.pending = Some(action);
|
||||
self.sidecar_allowed = false;
|
||||
}
|
||||
|
||||
/// Record that the user accepted Sidecar for this session.
|
||||
pub const fn allow_sidecar(&mut self) {
|
||||
self.pending = None;
|
||||
self.sidecar_allowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(health: ServiceHealth, is_admin: bool, op_in_flight: bool) -> RunState {
|
||||
RunState {
|
||||
health,
|
||||
pending: None,
|
||||
sidecar_allowed: false,
|
||||
mode: RunningMode::NotRunning,
|
||||
is_admin,
|
||||
op_in_flight,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_usable_requires_no_operation_in_flight() {
|
||||
assert!(state(ServiceHealth::Ready, false, false).service_usable());
|
||||
assert!(!state(ServiceHealth::Ready, false, true).service_usable());
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, false).service_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_ready_ignores_operations_in_flight() {
|
||||
assert!(state(ServiceHealth::Ready, false, true).service_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_capable_when_elevated_even_without_service() {
|
||||
assert!(state(ServiceHealth::NotInstalled, true, false).tun_capable());
|
||||
assert!(state(ServiceHealth::Unavailable("boom".into()), true, true).tun_capable());
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, false).tun_capable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_that_is_merely_absent_needs_no_decision() {
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, false).service_needs_attention());
|
||||
assert!(!state(ServiceHealth::Ready, false, false).service_needs_attention());
|
||||
assert!(!state(ServiceHealth::Unknown, false, false).service_needs_attention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_but_unusable_service_needs_a_decision() {
|
||||
assert!(state(ServiceHealth::VersionMismatch, false, false).service_needs_attention());
|
||||
assert!(state(ServiceHealth::Unavailable("boom".into()), false, false).service_needs_attention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_action_needs_an_answer_until_it_is_being_carried_out() {
|
||||
for action in [
|
||||
PendingAction::Install,
|
||||
PendingAction::Uninstall,
|
||||
PendingAction::Reinstall,
|
||||
PendingAction::ForceReinstall,
|
||||
] {
|
||||
let mut run_state = state(ServiceHealth::NotInstalled, false, false);
|
||||
run_state.pending = Some(action);
|
||||
assert!(run_state.service_needs_attention(), "{action:?} is still a question");
|
||||
|
||||
run_state.op_in_flight = true;
|
||||
assert!(
|
||||
!run_state.service_needs_attention(),
|
||||
"{action:?} is under way, which is progress rather than a question"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operation_under_way_is_never_a_decision_to_put_to_the_user() {
|
||||
// The regression this pins: an install or uninstall started from the settings page
|
||||
// pushes its pending action to the frontend the moment it is requested. Readers used
|
||||
// to block until the operation finished and so never saw it; now that every transition
|
||||
// is pushed, treating it as a decision raises the migration dialog on top of an
|
||||
// operation already running — and for an uninstall, one offering to reinstall.
|
||||
for health in [
|
||||
ServiceHealth::Unknown,
|
||||
ServiceHealth::Ready,
|
||||
ServiceHealth::NotInstalled,
|
||||
ServiceHealth::VersionMismatch,
|
||||
ServiceHealth::Unavailable("boom".into()),
|
||||
] {
|
||||
let mut run_state = state(health.clone(), false, true);
|
||||
run_state.pending = Some(PendingAction::Uninstall);
|
||||
|
||||
assert!(!run_state.service_needs_attention(), "{health:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_settled_on_sidecar_needs_nothing() {
|
||||
let mut run_state = state(ServiceHealth::VersionMismatch, false, false);
|
||||
run_state.sidecar_allowed = true;
|
||||
|
||||
assert!(!run_state.service_needs_attention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_carries_the_derived_answers_and_the_unavailable_reason() {
|
||||
let mut run_state = state(ServiceHealth::Unavailable("socket refused".into()), true, false);
|
||||
run_state.pending = Some(PendingAction::Reinstall);
|
||||
|
||||
let view = run_state.to_view();
|
||||
|
||||
assert_eq!(view.service, "unavailable");
|
||||
assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused"));
|
||||
assert_eq!(view.pending_action, Some(PendingAction::Reinstall));
|
||||
assert!(view.tun_capable, "elevation alone makes TUN possible");
|
||||
assert!(!view.service_usable);
|
||||
assert!(view.service_needs_attention);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_healthy_view_reports_no_unavailable_reason() {
|
||||
let view = state(ServiceHealth::Ready, false, false).to_view();
|
||||
|
||||
assert_eq!(view.service, "ready");
|
||||
assert_eq!(view.service_unavailable_reason, None);
|
||||
assert!(view.service_usable);
|
||||
assert!(!view.service_needs_attention);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_left_alone_when_the_user_has_not_asked_for_it() {
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_left_alone_while_it_still_works() {
|
||||
assert!(!state(ServiceHealth::Ready, false, false).tun_should_be_disabled(true));
|
||||
assert!(!state(ServiceHealth::NotInstalled, true, false).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_disabled_once_we_know_it_cannot_work() {
|
||||
assert!(state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_while_an_operation_is_in_flight() {
|
||||
// Mid-install the service is not usable, but that says nothing about what it will be
|
||||
// once the operation finishes.
|
||||
assert!(!state(ServiceHealth::Ready, false, true).tun_should_be_disabled(true));
|
||||
assert!(!state(ServiceHealth::NotInstalled, false, true).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_while_the_core_is_not_running() {
|
||||
// The startup regression this guards: the service is observed as absent before the
|
||||
// startup path has decided whether to install it, and acting on that snapshot both
|
||||
// rewrites the user's setting and removes the prompt that would have fixed it.
|
||||
// `reconcile_tun_availability` refuses to act at all while the mode is NotRunning;
|
||||
// this pins the state that made it dangerous.
|
||||
let stopped = state(ServiceHealth::NotInstalled, false, false);
|
||||
|
||||
assert_eq!(stopped.mode, RunningMode::NotRunning);
|
||||
assert!(
|
||||
stopped.tun_should_be_disabled(true),
|
||||
"the predicate alone says yes, which is why the caller must gate on the mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_on_an_unprobed_service() {
|
||||
// The old frontend guard waited out a fixed ten seconds to approximate this.
|
||||
assert!(!state(ServiceHealth::Unknown, false, false).tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_never_disabled_while_a_decision_is_pending() {
|
||||
for health in [
|
||||
ServiceHealth::VersionMismatch,
|
||||
ServiceHealth::Unavailable("boom".into()),
|
||||
] {
|
||||
assert!(
|
||||
!state(health.clone(), false, false).tun_should_be_disabled(true),
|
||||
"{health:?} is the user's to resolve",
|
||||
);
|
||||
}
|
||||
|
||||
let mut requested = state(ServiceHealth::NotInstalled, false, false);
|
||||
requested.pending = Some(PendingAction::Install);
|
||||
assert!(!requested.tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_disabled_once_the_user_settles_on_sidecar() {
|
||||
let mut settled = state(ServiceHealth::NotInstalled, false, false);
|
||||
settled.sidecar_allowed = true;
|
||||
|
||||
assert!(settled.tun_should_be_disabled(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observing_clears_a_pending_request() {
|
||||
let mut stored = StoredService::default();
|
||||
stored.request(PendingAction::Install);
|
||||
stored.observe(ServiceHealth::Ready);
|
||||
|
||||
assert_eq!(stored.pending, None);
|
||||
assert!(!stored.sidecar_allowed);
|
||||
assert_eq!(stored.health, ServiceHealth::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requesting_and_allowing_sidecar_are_mutually_exclusive() {
|
||||
let mut stored = StoredService::default();
|
||||
stored.observe(ServiceHealth::NotInstalled);
|
||||
|
||||
stored.request(PendingAction::Install);
|
||||
assert!(!stored.sidecar_allowed);
|
||||
|
||||
stored.allow_sidecar();
|
||||
assert_eq!(stored.pending, None);
|
||||
assert!(stored.sidecar_allowed);
|
||||
|
||||
// The observation survives both requests.
|
||||
assert_eq!(stored.health, ServiceHealth::NotInstalled);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
//! Deciding when the Service has stopped being ours.
|
||||
//!
|
||||
//! While the Core runs under the Service, the app polls the Service for owner status. Three
|
||||
//! things can go wrong — we cannot read a reply, the Service says someone else owns it, or it
|
||||
//! answers but describes a Core that is gone — and each needs a different response. This is
|
||||
//! the decision, separated from the polling and from the recovery it triggers.
|
||||
//!
|
||||
//! Recovery *actions* deliberately live with the Core lifecycle rather than here: this module
|
||||
//! decides that the Service can no longer be trusted, and `CoreManager` is what tears the Core
|
||||
//! down in response.
|
||||
|
||||
use clash_verge_service_ipc::ServiceLifecycleState;
|
||||
|
||||
/// How many consecutive bad samples we tolerate before acting.
|
||||
///
|
||||
/// Service restarts and slow status calls are normal; acting on the first one would tear down
|
||||
/// a working proxy for a blip.
|
||||
const SUSTAINED_SAMPLES: u8 = 3;
|
||||
|
||||
/// Why the app stopped trusting the Service that was running its Core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OwnerRecoveryReason {
|
||||
/// Another owner took the Service over.
|
||||
Displaced,
|
||||
/// Still ours, but the Core it was running is gone.
|
||||
SameOwnerFailure,
|
||||
/// We could not reach the Service at all for long enough to give up.
|
||||
TransportFailure,
|
||||
}
|
||||
|
||||
/// One reading of Service owner status.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OwnerSample {
|
||||
/// No usable answer: transport failed, an error code came back, or the payload was empty.
|
||||
Unreadable,
|
||||
/// The Service reports it is no longer running for us.
|
||||
NotActive,
|
||||
/// A complete answer.
|
||||
Status {
|
||||
is_active: bool,
|
||||
desired_core_should_be_running: bool,
|
||||
service_state: ServiceLifecycleState,
|
||||
core_pid: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the caller should do about a sample.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OwnerStep {
|
||||
/// Nothing is wrong, or not wrong for long enough yet.
|
||||
Continue,
|
||||
/// Unreadable for long enough that it might be real. Check whether the Core's own endpoint
|
||||
/// still answers, then report back via [`OwnerWatch::resolve_transport`].
|
||||
///
|
||||
/// A Service we cannot reach while the Core is still serving is a broken status channel,
|
||||
/// not a lost Core — tearing down the proxy there would be the cure causing the disease.
|
||||
VerifyTransport,
|
||||
/// Stop trusting the Service and recover.
|
||||
Recover(OwnerRecoveryReason),
|
||||
}
|
||||
|
||||
/// Counts consecutive bad samples so that a blip is not mistaken for a failure.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OwnerWatch {
|
||||
unreadable_samples: u8,
|
||||
missing_core_samples: u8,
|
||||
}
|
||||
|
||||
impl OwnerWatch {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
unreadable_samples: 0,
|
||||
missing_core_samples: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this sample warrants a log line: only the moment a run becomes sustained, so a
|
||||
/// permanently broken Service does not fill the log at the poll interval.
|
||||
#[must_use]
|
||||
pub const fn just_became_sustained(&self) -> bool {
|
||||
self.unreadable_samples == SUSTAINED_SAMPLES
|
||||
}
|
||||
|
||||
/// Fold one reading into the decision.
|
||||
pub const fn observe(&mut self, sample: OwnerSample) -> OwnerStep {
|
||||
match sample {
|
||||
OwnerSample::Unreadable => {
|
||||
self.unreadable_samples = self.unreadable_samples.saturating_add(1);
|
||||
if self.unreadable_samples >= SUSTAINED_SAMPLES {
|
||||
OwnerStep::VerifyTransport
|
||||
} else {
|
||||
OwnerStep::Continue
|
||||
}
|
||||
}
|
||||
OwnerSample::NotActive => OwnerStep::Recover(OwnerRecoveryReason::Displaced),
|
||||
OwnerSample::Status {
|
||||
is_active,
|
||||
desired_core_should_be_running,
|
||||
service_state,
|
||||
core_pid,
|
||||
} => {
|
||||
self.unreadable_samples = 0;
|
||||
self.missing_core_samples = if core_pid.is_none() && !is_settling(service_state) {
|
||||
self.missing_core_samples.saturating_add(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
match recovery_reason(
|
||||
is_active,
|
||||
desired_core_should_be_running,
|
||||
service_state,
|
||||
core_pid,
|
||||
self.missing_core_samples,
|
||||
) {
|
||||
Some(reason) => OwnerStep::Recover(reason),
|
||||
None => OwnerStep::Continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Report what the transport check found after [`OwnerStep::VerifyTransport`].
|
||||
///
|
||||
/// If the Core's own endpoint still answers, only the status channel is broken: the run is
|
||||
/// reset so the next stretch of silence has to earn its own verdict.
|
||||
pub const fn resolve_transport(&mut self, owner_endpoint_available: bool) -> OwnerStep {
|
||||
if owner_endpoint_available {
|
||||
self.unreadable_samples = 0;
|
||||
return OwnerStep::Continue;
|
||||
}
|
||||
OwnerStep::Recover(OwnerRecoveryReason::TransportFailure)
|
||||
}
|
||||
}
|
||||
|
||||
/// States in which a missing Core PID is expected rather than alarming.
|
||||
const fn is_settling(service_state: ServiceLifecycleState) -> bool {
|
||||
matches!(
|
||||
service_state,
|
||||
ServiceLifecycleState::Starting | ServiceLifecycleState::RecoveringCore
|
||||
)
|
||||
}
|
||||
|
||||
const fn recovery_reason(
|
||||
is_active: bool,
|
||||
desired_running: bool,
|
||||
service_state: ServiceLifecycleState,
|
||||
core_pid: Option<u32>,
|
||||
missing_core_samples: u8,
|
||||
) -> Option<OwnerRecoveryReason> {
|
||||
if !is_active {
|
||||
return Some(OwnerRecoveryReason::Displaced);
|
||||
}
|
||||
let core_is_gone = !is_settling(service_state) && core_pid.is_none() && missing_core_samples >= SUSTAINED_SAMPLES;
|
||||
if !desired_running || matches!(service_state, ServiceLifecycleState::Fatal) || core_is_gone {
|
||||
return Some(OwnerRecoveryReason::SameOwnerFailure);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const fn healthy() -> OwnerSample {
|
||||
OwnerSample::Status {
|
||||
is_active: true,
|
||||
desired_core_should_be_running: true,
|
||||
service_state: ServiceLifecycleState::Running,
|
||||
core_pid: Some(42),
|
||||
}
|
||||
}
|
||||
|
||||
const fn core_gone(service_state: ServiceLifecycleState) -> OwnerSample {
|
||||
OwnerSample::Status {
|
||||
is_active: true,
|
||||
desired_core_should_be_running: true,
|
||||
service_state,
|
||||
core_pid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_healthy_sample_changes_nothing() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
assert_eq!(watch.observe(healthy()), OwnerStep::Continue);
|
||||
assert_eq!(watch, OwnerWatch::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_run_of_unreadable_samples_is_tolerated() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sustained_run_of_unreadable_samples_asks_about_the_transport() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
for _ in 0..2 {
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
}
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport);
|
||||
assert!(watch.just_became_sustained(), "the sustained moment should log once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sustained_run_logs_only_once_however_long_it_lasts() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
for _ in 0..3 {
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
}
|
||||
assert!(watch.just_became_sustained());
|
||||
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
assert!(!watch.just_became_sustained());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reachable_core_endpoint_means_only_the_status_channel_broke() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
for _ in 0..3 {
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
}
|
||||
|
||||
assert_eq!(watch.resolve_transport(true), OwnerStep::Continue);
|
||||
|
||||
// The run restarted: the next stretch of silence must earn its own verdict.
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_core_endpoint_confirms_transport_failure() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
for _ in 0..3 {
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
watch.resolve_transport(false),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::TransportFailure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_readable_sample_clears_an_unreadable_run() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
|
||||
watch.observe(healthy());
|
||||
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_reporting_not_active_is_displacement() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
assert_eq!(
|
||||
watch.observe(OwnerSample::NotActive),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::Displaced)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_saying_we_are_not_the_owner_is_displacement() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
let sample = OwnerSample::Status {
|
||||
is_active: false,
|
||||
desired_core_should_be_running: true,
|
||||
service_state: ServiceLifecycleState::Running,
|
||||
core_pid: Some(1),
|
||||
};
|
||||
assert_eq!(
|
||||
watch.observe(sample),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::Displaced)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fatal_service_recovers_immediately() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
let sample = OwnerSample::Status {
|
||||
is_active: true,
|
||||
desired_core_should_be_running: true,
|
||||
service_state: ServiceLifecycleState::Fatal,
|
||||
core_pid: Some(1),
|
||||
};
|
||||
assert_eq!(
|
||||
watch.observe(sample),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_that_no_longer_wants_the_core_running_recovers_immediately() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
let sample = OwnerSample::Status {
|
||||
is_active: true,
|
||||
desired_core_should_be_running: false,
|
||||
service_state: ServiceLifecycleState::Running,
|
||||
core_pid: Some(1),
|
||||
};
|
||||
assert_eq!(
|
||||
watch.observe(sample),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_briefly_missing_core_is_tolerated_then_recovered() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_core_is_expected_while_the_service_is_still_settling() {
|
||||
for state in [ServiceLifecycleState::Starting, ServiceLifecycleState::RecoveringCore] {
|
||||
let mut watch = OwnerWatch::new();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(watch.observe(core_gone(state)), OwnerStep::Continue, "{state:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_core_that_comes_back_clears_the_missing_run() {
|
||||
let mut watch = OwnerWatch::new();
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running));
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running));
|
||||
|
||||
watch.observe(healthy());
|
||||
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_run_does_not_count_towards_a_missing_core() {
|
||||
// The two counters are independent: silence says nothing about the Core's PID.
|
||||
let mut watch = OwnerWatch::new();
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
watch.observe(OwnerSample::Unreadable);
|
||||
|
||||
assert_eq!(
|
||||
watch.observe(core_gone(ServiceLifecycleState::Running)),
|
||||
OwnerStep::Continue
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Turning raw Service probes into [`ServiceHealth`].
|
||||
//!
|
||||
//! Everything here is a pure function of its arguments: the probing itself is an effect
|
||||
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
|
||||
//! systemd, SCM or launchd.
|
||||
|
||||
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
|
||||
|
||||
use super::health::ServiceHealth;
|
||||
|
||||
/// What the Service replied to a protocol-version query.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServiceVersionReply {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
pub protocol: Option<ProtocolInfo>,
|
||||
}
|
||||
|
||||
/// The verdict on a [`ServiceVersionReply`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ServiceVersionCheck {
|
||||
Ready,
|
||||
NeedsReinstall(String),
|
||||
}
|
||||
|
||||
/// What a live probe of the currently-installed Service told us.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CurrentServiceProbe {
|
||||
Missing,
|
||||
Ready,
|
||||
VersionMismatch,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Decide whether the Service we reached speaks a protocol this client can use.
|
||||
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
|
||||
let client = ProtocolVersion::current();
|
||||
if reply.code == 0
|
||||
&& reply
|
||||
.protocol
|
||||
.as_ref()
|
||||
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
|
||||
{
|
||||
return ServiceVersionCheck::Ready;
|
||||
}
|
||||
|
||||
let detail = if reply.code == 0 {
|
||||
match reply.protocol.as_ref() {
|
||||
Some(info) => format!(
|
||||
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
|
||||
client.epoch,
|
||||
MIN_REQUIRED_SERVICE_REVISION,
|
||||
info.protocol.epoch,
|
||||
info.protocol.revision,
|
||||
info.build_version
|
||||
),
|
||||
None => "service did not report protocol information".to_owned(),
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"protocol query returned code {} ({}) while expecting epoch {}",
|
||||
reply.code, reply.message, client.epoch
|
||||
)
|
||||
};
|
||||
ServiceVersionCheck::NeedsReinstall(format!(
|
||||
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
|
||||
))
|
||||
}
|
||||
|
||||
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
|
||||
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
|
||||
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
|
||||
CurrentServiceProbe::Ready
|
||||
} else {
|
||||
CurrentServiceProbe::VersionMismatch
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine a live probe with platform installation evidence into a health verdict.
|
||||
///
|
||||
/// The `has_install_marker` case matters: a Service registered with the platform that does
|
||||
/// not answer is broken, not absent, so it needs reinstalling rather than installing.
|
||||
/// `unavailable_reason` is only consulted for [`CurrentServiceProbe::Unavailable`].
|
||||
pub fn classify_service_health(
|
||||
current: CurrentServiceProbe,
|
||||
has_install_marker: bool,
|
||||
unavailable_reason: &str,
|
||||
) -> ServiceHealth {
|
||||
match current {
|
||||
CurrentServiceProbe::Ready => ServiceHealth::Ready,
|
||||
CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch,
|
||||
CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.to_owned()),
|
||||
CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch,
|
||||
CurrentServiceProbe::Missing => ServiceHealth::NotInstalled,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ready_reply() -> ServiceVersionReply {
|
||||
ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compatible_reply_is_ready() {
|
||||
assert_eq!(
|
||||
classify_service_version_reply(&ready_reply()),
|
||||
ServiceVersionCheck::Ready
|
||||
);
|
||||
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_without_protocol_information_needs_reinstall() {
|
||||
let reply = ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: None,
|
||||
};
|
||||
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
|
||||
panic!("expected a reinstall verdict");
|
||||
};
|
||||
assert!(
|
||||
detail.contains("did not report protocol information"),
|
||||
"detail should explain the missing protocol: {detail}"
|
||||
);
|
||||
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonzero_code_needs_reinstall() {
|
||||
let reply = ServiceVersionReply {
|
||||
code: 7,
|
||||
message: "nope".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
};
|
||||
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
|
||||
panic!("expected a reinstall verdict");
|
||||
};
|
||||
assert!(detail.contains("code 7"), "detail should name the code: {detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mismatched_epoch_needs_reinstall() {
|
||||
let mut info = ProtocolInfo::current();
|
||||
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
|
||||
let reply = ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(info),
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_service_version_reply(&reply),
|
||||
ServiceVersionCheck::NeedsReinstall(_)
|
||||
));
|
||||
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_revision_below_the_minimum_needs_reinstall() {
|
||||
let Some(revision) = MIN_REQUIRED_SERVICE_REVISION.checked_sub(1) else {
|
||||
// Nothing below the minimum exists to test against.
|
||||
return;
|
||||
};
|
||||
let mut info = ProtocolInfo::current();
|
||||
info.protocol.revision = revision;
|
||||
let reply = ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(info),
|
||||
};
|
||||
assert!(matches!(
|
||||
classify_service_version_reply(&reply),
|
||||
ServiceVersionCheck::NeedsReinstall(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_rejection_tells_the_user_what_to_do_about_it() {
|
||||
let mut old_epoch = ProtocolInfo::current();
|
||||
old_epoch.protocol.epoch = old_epoch.protocol.epoch.wrapping_add(1);
|
||||
|
||||
for protocol in [None, Some(old_epoch)] {
|
||||
for code in [0, 1] {
|
||||
let reply = ServiceVersionReply {
|
||||
code,
|
||||
message: "unsupported command".to_owned(),
|
||||
protocol: protocol.clone(),
|
||||
};
|
||||
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
detail.contains("epoch") || detail.contains("protocol"),
|
||||
"detail should say what mismatched: {detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("Reinstall") || detail.contains("Repair"),
|
||||
"detail should name the action that fixes it: {detail}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_without_evidence_is_not_installed() {
|
||||
assert_eq!(
|
||||
classify_service_health(CurrentServiceProbe::Missing, false, ""),
|
||||
ServiceHealth::NotInstalled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_with_evidence_means_broken_not_absent() {
|
||||
assert_eq!(
|
||||
classify_service_health(CurrentServiceProbe::Missing, true, ""),
|
||||
ServiceHealth::VersionMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_service_keeps_the_reason() {
|
||||
assert_eq!(
|
||||
classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"),
|
||||
ServiceHealth::Unavailable("socket refused".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_probe_dominates_the_install_marker() {
|
||||
for has_marker in [false, true] {
|
||||
assert_eq!(
|
||||
classify_service_health(CurrentServiceProbe::Ready, has_marker, ""),
|
||||
ServiceHealth::Ready
|
||||
);
|
||||
assert_eq!(
|
||||
classify_service_health(CurrentServiceProbe::VersionMismatch, has_marker, ""),
|
||||
ServiceHealth::VersionMismatch
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
||||
use serde_yaml_ng::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
const GEO_ASSETS: &[&str] = &[
|
||||
"Country.mmdb",
|
||||
"geoip.dat",
|
||||
"geosite.dat",
|
||||
"geoip.metadb",
|
||||
"GeoSite.dat",
|
||||
];
|
||||
|
||||
pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) -> Result<RuntimeBundle> {
|
||||
let yaml = tokio::fs::read_to_string(config_file)
|
||||
.await
|
||||
.with_context(|| format!("failed to read runtime config {config_file:?}"))?;
|
||||
let mut config: Value =
|
||||
serde_yaml_ng::from_str(&yaml).with_context(|| format!("failed to parse runtime config {config_file:?}"))?;
|
||||
let config_root = config_file
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime config has no parent directory"))?;
|
||||
let config_root = std::fs::canonicalize(config_root)?;
|
||||
let mut assets = Vec::new();
|
||||
let mut destinations = HashSet::new();
|
||||
let mut remote_providers = Vec::new();
|
||||
|
||||
collect_provider_assets(
|
||||
&mut config,
|
||||
"proxy-providers",
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
&mut remote_providers,
|
||||
)?;
|
||||
collect_provider_assets(
|
||||
&mut config,
|
||||
"rule-providers",
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
&mut remote_providers,
|
||||
)?;
|
||||
for filename in GEO_ASSETS {
|
||||
let source = config_root.join(filename);
|
||||
if source.is_file() && destinations.insert((*filename).to_string()) {
|
||||
assets.push(RuntimeAsset {
|
||||
source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(),
|
||||
destination: (*filename).to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RuntimeBundle {
|
||||
yaml: serde_yaml_ng::to_string(&config).context("failed to serialize service runtime config")?,
|
||||
assets,
|
||||
remote_providers,
|
||||
core_path: core_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrite one provider section's paths to service-side destinations, and record what each
|
||||
/// destination is going to hold.
|
||||
///
|
||||
/// The two outputs are not the same thing. `assets` are files the app owns and the service copies;
|
||||
/// `remote_providers` are files the *core* downloads, which the service never writes and only
|
||||
/// needs to recognise — a download cache is reusable exactly while the url that produced it is
|
||||
/// unchanged, and the service has no other way to tell.
|
||||
fn collect_provider_assets(
|
||||
config: &mut Value,
|
||||
section: &str,
|
||||
config_root: &Path,
|
||||
destinations: &mut HashSet<String>,
|
||||
assets: &mut Vec<RuntimeAsset>,
|
||||
remote_providers: &mut Vec<RemoteProvider>,
|
||||
) -> Result<()> {
|
||||
let Some(providers) = config
|
||||
.as_mapping_mut()
|
||||
.and_then(|mapping| mapping.get_mut(section))
|
||||
.and_then(Value::as_mapping_mut)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for (name, provider) in providers.iter_mut() {
|
||||
let Some(provider) = provider.as_mapping_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
// The Core decides what a provider is by its `type`, so anything else here would disagree
|
||||
// with it. Keying off the presence of a `url` used to do that: a `file` provider carrying
|
||||
// a stray `url` was treated as remote, its local file left uncopied, and the Core then
|
||||
// could not find what the rewritten path pointed at.
|
||||
//
|
||||
// Disagreeing the other way is just as bad, though, and it is why neither branch may fail
|
||||
// the whole bundle over one odd provider. Measured against mihomo v1.19.26: it accepts a
|
||||
// `http` provider with no url, an `inline` provider carrying both a url and a path that
|
||||
// does not exist, and a `file` provider whose path is missing. Refusing any of those here
|
||||
// would turn a configuration the Core is willing to load into one that cannot be applied
|
||||
// at all — including at start, since this is where a Service-mode start gets its bundle.
|
||||
// A provider we cannot classify keeps its path unrewritten and is simply not declared.
|
||||
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
|
||||
let url = provider.get("url").and_then(Value::as_str).map(str::to_owned);
|
||||
let destination = match (is_remote, url) {
|
||||
(true, Some(url)) => {
|
||||
let destination = provider_destination(config_root, raw_path)?;
|
||||
// Remote providers take their destination out of circulation as much as local ones
|
||||
// do: the Service refuses a bundle where one path is claimed both by a file it must
|
||||
// copy and by a file the Core downloads, because the two would overwrite each
|
||||
// other. Two providers naming the same file *and* the same source are not that —
|
||||
// they are one file with two names for it, which the Service folds.
|
||||
match remote_providers
|
||||
.iter()
|
||||
.find(|declared| declared.destination == destination)
|
||||
{
|
||||
Some(declared) if declared.url == url => {}
|
||||
Some(_) => {
|
||||
bail!("runtime provider destination {destination:?} is declared for two different sources")
|
||||
}
|
||||
None => {
|
||||
if !destinations.insert(destination.clone()) {
|
||||
bail!("runtime provider destination {destination:?} is claimed more than once");
|
||||
}
|
||||
remote_providers.push(RemoteProvider {
|
||||
destination: destination.clone(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
destination
|
||||
}
|
||||
(true, None) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"remote provider {name:?} declares no url; leaving its path to the core"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(false, _) => {
|
||||
let Ok(source) = local_provider_source(config_root, raw_path) else {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"local provider {name:?} is unavailable at {raw_path:?}; leaving its path to the core"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let destination = destination_below_root(config_root, &source)?;
|
||||
if destinations.insert(destination.clone()) {
|
||||
assets.push(RuntimeAsset {
|
||||
source: source.to_string_lossy().into_owned(),
|
||||
destination: destination.clone(),
|
||||
});
|
||||
}
|
||||
destination
|
||||
}
|
||||
};
|
||||
provider.insert(Value::String("path".to_owned()), Value::String(destination));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
|
||||
let path = Path::new(raw_path);
|
||||
let source = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
config_root.join(path)
|
||||
};
|
||||
let canonical_source =
|
||||
std::fs::canonicalize(&source).with_context(|| format!("local runtime provider is unavailable: {source:?}"))?;
|
||||
canonical_source
|
||||
.strip_prefix(config_root)
|
||||
.map_err(|_| anyhow::anyhow!("local runtime provider is outside the config root: {canonical_source:?}"))?;
|
||||
Ok(canonical_source)
|
||||
}
|
||||
|
||||
fn provider_destination(config_root: &Path, raw_path: &str) -> Result<String> {
|
||||
let path = Path::new(raw_path);
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
bail!("runtime provider destination traverses outside the runtime");
|
||||
}
|
||||
if path.is_absolute() {
|
||||
let normalized = canonicalize_with_missing_tail(path)?;
|
||||
destination_below_root(config_root, &normalized)
|
||||
} else {
|
||||
normalized_destination(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_with_missing_tail(path: &Path) -> Result<PathBuf> {
|
||||
let mut ancestor = path;
|
||||
let mut tail = Vec::new();
|
||||
while !ancestor.exists() {
|
||||
let name = ancestor
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
|
||||
tail.push(name.to_owned());
|
||||
ancestor = ancestor
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
|
||||
}
|
||||
let mut normalized = std::fs::canonicalize(ancestor)?;
|
||||
for component in tail.iter().rev() {
|
||||
normalized.push(component);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn destination_below_root(config_root: &Path, path: &Path) -> Result<String> {
|
||||
let relative = path
|
||||
.strip_prefix(config_root)
|
||||
.map_err(|_| anyhow::anyhow!("runtime provider path is outside the config root: {path:?}"))?;
|
||||
normalized_destination(relative)
|
||||
}
|
||||
|
||||
fn normalized_destination(relative: &Path) -> Result<String> {
|
||||
let mut destination = PathBuf::new();
|
||||
for component in relative.components() {
|
||||
match component {
|
||||
Component::Normal(component) => destination.push(component),
|
||||
Component::CurDir => {}
|
||||
_ => bail!("local runtime provider destination traverses outside the runtime"),
|
||||
}
|
||||
}
|
||||
if destination.as_os_str().is_empty() {
|
||||
bail!("local runtime provider destination is empty");
|
||||
}
|
||||
Ok(destination.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_runtime_bundle;
|
||||
|
||||
#[tokio::test]
|
||||
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("providers"))?;
|
||||
std::fs::write(root.join("providers/local.yaml"), b"proxies: []\n")?;
|
||||
std::fs::write(root.join("Country.mmdb"), b"geo")?;
|
||||
let config = root.join("config.yaml");
|
||||
let absolute_local = root.join("providers/local.yaml");
|
||||
let absolute_remote = root.join("providers/remote.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"proxy-providers:\n local:\n type: file\n path: {}\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\nrule-providers: {{}}\n",
|
||||
absolute_local.display(),
|
||||
absolute_remote.display()
|
||||
),
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let bundle = collect_runtime_bundle(&config, &core).await?;
|
||||
|
||||
let bundled_config: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?;
|
||||
assert_eq!(
|
||||
bundled_config["proxy-providers"]["local"]["path"].as_str(),
|
||||
Some("providers/local.yaml")
|
||||
);
|
||||
assert_eq!(
|
||||
bundled_config["proxy-providers"]["remote"]["path"].as_str(),
|
||||
Some("providers/remote.yaml")
|
||||
);
|
||||
assert!(!bundle.yaml.contains(&root.to_string_lossy().into_owned()));
|
||||
assert!(
|
||||
bundle
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.destination == "providers/local.yaml")
|
||||
);
|
||||
assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb"));
|
||||
assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote")));
|
||||
// A remote provider is not copied, but it must still be declared: the Service decides
|
||||
// whether the Core's download cache is reusable by comparing the url that produced it,
|
||||
// and an undeclared provider is one it can only ever discard.
|
||||
assert_eq!(
|
||||
bundle
|
||||
.remote_providers
|
||||
.iter()
|
||||
.map(|provider| (provider.destination.as_str(), provider.url.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[("providers/remote.yaml", "https://example.com/p.yaml")],
|
||||
"the declaration must pair the rewritten destination with the url, not the original path"
|
||||
);
|
||||
assert!(
|
||||
!bundle
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.destination.contains("geoip.dat"))
|
||||
);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> {
|
||||
// The Service folds an identical repeat, so refusing it here would block a configuration
|
||||
// mihomo itself accepts — and this is also where a Service-mode *start* gets its bundle,
|
||||
// so the refusal would stop the core coming up at all.
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let config = root.join("config.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n",
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let bundle = collect_runtime_bundle(&config, &core).await?;
|
||||
|
||||
assert_eq!(bundle.remote_providers.len(), 1, "one file, declared twice");
|
||||
assert_eq!(bundle.remote_providers[0].destination, "rules/x.yaml");
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_sources_for_one_remote_file_are_refused() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-bundle-conflict-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let config = root.join("config.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://two.example/x.yaml\n path: ./rules/x.yaml\n",
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let Err(error) = collect_runtime_bundle(&config, &core).await else {
|
||||
anyhow::bail!("two sources cannot own one file");
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains("two different sources"), "{error}");
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> {
|
||||
// Measured against mihomo v1.19.26: it accepts a `http` provider with no url, and a
|
||||
// non-http provider whose path does not exist. Neither may stop the rest being materialised.
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let config = root.join("config.yaml");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"rule-providers:\n nourl:\n type: http\n path: ./rules/nourl.yaml\n missing:\n type: inline\n url: https://one.example/i.yaml\n path: ./rules/missing.yaml\n good:\n type: http\n url: https://one.example/g.yaml\n path: ./rules/g.yaml\n",
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let bundle = collect_runtime_bundle(&config, &core).await?;
|
||||
|
||||
assert_eq!(
|
||||
bundle.remote_providers.len(),
|
||||
1,
|
||||
"only the provider that could be classified is declared"
|
||||
);
|
||||
assert_eq!(bundle.remote_providers[0].destination, "rules/g.yaml");
|
||||
let reserialized: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?;
|
||||
assert_eq!(
|
||||
reserialized["rule-providers"]["nourl"]["path"].as_str(),
|
||||
Some("./rules/nourl.yaml"),
|
||||
"an unclassifiable provider keeps the path the core was given"
|
||||
);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let config = root.join("config.yaml");
|
||||
let outside = root
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("test root has no parent"))?
|
||||
.join(format!("outside-{}.yaml", std::process::id()));
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"proxy-providers:\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\n",
|
||||
outside.display()
|
||||
),
|
||||
)?;
|
||||
let core = root.join("mihomo");
|
||||
std::fs::write(&core, b"core")?;
|
||||
|
||||
let Err(error) = collect_runtime_bundle(&config, &core).await else {
|
||||
anyhow::bail!("outside provider path must be rejected");
|
||||
};
|
||||
|
||||
assert!(error.to_string().contains("outside the config root"));
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+1660
-314
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
config::{Config, MixedPort},
|
||||
singleton,
|
||||
utils::server,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
@@ -33,8 +34,9 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Sysopt {
|
||||
pub(crate) struct Sysopt {
|
||||
update_lock: TokioMutex<()>,
|
||||
guard_operation_lock: TokioMutex<()>,
|
||||
reset_sysproxy: AtomicBool,
|
||||
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
|
||||
guard: Arc<RwLock<GuardMonitor>>,
|
||||
@@ -44,6 +46,7 @@ impl Default for Sysopt {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_lock: TokioMutex::new(()),
|
||||
guard_operation_lock: TokioMutex::new(()),
|
||||
reset_sysproxy: AtomicBool::new(false),
|
||||
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
|
||||
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
|
||||
@@ -84,17 +87,35 @@ impl Sysopt {
|
||||
Arc::clone(&self.guard)
|
||||
}
|
||||
|
||||
pub async fn refresh_guard(&self) {
|
||||
async fn stop_proxy_guard_locked(&self) {
|
||||
loop {
|
||||
let state = self.access_guard().read().get_state();
|
||||
if state.is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
continue;
|
||||
}
|
||||
self.access_guard().write().stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn stop_proxy_guard(&self) {
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_guard(&self) {
|
||||
logging!(info, Type::Core, "Refreshing system proxy guard...");
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let _operation = self.guard_operation_lock.lock().await;
|
||||
if !verge.enable_system_proxy.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy is disabled.");
|
||||
self.access_guard().write().stop();
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
}
|
||||
if !verge.enable_proxy_guard.unwrap_or_default() {
|
||||
logging!(info, Type::Core, "System proxy guard is disabled.");
|
||||
self.access_guard().write().stop();
|
||||
self.stop_proxy_guard_locked().await;
|
||||
return;
|
||||
}
|
||||
logging!(
|
||||
@@ -114,26 +135,27 @@ impl Sysopt {
|
||||
let guard = self.access_guard();
|
||||
guard.write().start();
|
||||
}
|
||||
while self.access_guard().read().get_state().is_pendding() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for any in-progress `update_sysproxy` to finish, so that a
|
||||
/// subsequent read of OS-level sysproxy state sees a fully applied
|
||||
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
||||
/// disabled but HTTP still enabled mid-transition).
|
||||
pub async fn wait_idle(&self) {
|
||||
pub(crate) async fn wait_idle(&self) {
|
||||
let _ = self.update_lock.lock().await;
|
||||
}
|
||||
|
||||
/// init the sysproxy
|
||||
pub async fn update_sysproxy(&self) -> Result<()> {
|
||||
pub(super) async fn update_sysproxy(&self) -> Result<()> {
|
||||
let _lock = self.update_lock.lock().await;
|
||||
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let port = match verge.verge_mixed_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.latest_arc().get_mixed_port(),
|
||||
};
|
||||
let pac_port = IVerge::get_singleton_port();
|
||||
// Configured, not live: this runs while the Core is being started or restarted, and
|
||||
// asking a Core that is not up yet would only fall back here anyway.
|
||||
let port = MixedPort::desired().await;
|
||||
let pac_port = server::embedded_server_port()?;
|
||||
// 先 await, 避免持有锁导致的 Send 问题
|
||||
let bypass = get_bypass().await;
|
||||
|
||||
@@ -178,6 +200,7 @@ impl Sysopt {
|
||||
(sys.clone(), auto.clone(), guard_type)
|
||||
};
|
||||
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.access_guard().write().set_guard_type(guard_type);
|
||||
|
||||
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
|
||||
@@ -197,7 +220,7 @@ impl Sysopt {
|
||||
}
|
||||
|
||||
/// reset the sysproxy
|
||||
pub async fn reset_sysproxy(&self) -> Result<()> {
|
||||
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
||||
if self
|
||||
.reset_sysproxy
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
@@ -208,9 +231,9 @@ impl Sysopt {
|
||||
defer! {
|
||||
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// close proxy guard
|
||||
self.access_guard().write().set_guard_type(GuardType::None);
|
||||
let _lock = self.update_lock.lock().await;
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
self.stop_proxy_guard_locked().await;
|
||||
|
||||
// 直接关闭所有代理
|
||||
let (sys, auto) = {
|
||||
|
||||
+127
-26
@@ -36,6 +36,24 @@ struct TaskState {
|
||||
running: bool,
|
||||
}
|
||||
|
||||
async fn run_timer_profile_update_transition<Started, Update, UpdateFuture, Finished, Completed, Output>(
|
||||
update_started: Started,
|
||||
update_profile: Update,
|
||||
update_finished: Finished,
|
||||
update_completed: Completed,
|
||||
) where
|
||||
Started: FnOnce(),
|
||||
Update: FnOnce() -> UpdateFuture,
|
||||
UpdateFuture: std::future::Future<Output = Output>,
|
||||
Finished: FnOnce(Output),
|
||||
Completed: FnOnce(),
|
||||
{
|
||||
update_started();
|
||||
let result = update_profile().await;
|
||||
update_finished(result);
|
||||
update_completed();
|
||||
}
|
||||
|
||||
impl TaskState {
|
||||
const fn new(key: Key, interval_minutes: u64) -> Self {
|
||||
Self {
|
||||
@@ -386,36 +404,35 @@ impl Timer {
|
||||
let task_start = std::time::Instant::now();
|
||||
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
|
||||
Self::emit_update_event(uid, true);
|
||||
|
||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||
logging!(
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Profile {} is current active profile: {}",
|
||||
uid,
|
||||
is_current
|
||||
);
|
||||
|
||||
feat::update_profile(uid, None, is_current, false, false).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
run_timer_profile_update_transition(
|
||||
|| Self::emit_update_event(uid, true),
|
||||
|| async {
|
||||
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
|
||||
logging!(
|
||||
info,
|
||||
debug,
|
||||
Type::Timer,
|
||||
"Timer task completed for uid: {} (took {}ms)",
|
||||
"Profile {} is current active profile: {}",
|
||||
uid,
|
||||
task_start.elapsed().as_millis()
|
||||
is_current
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
|
||||
Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid),
|
||||
}
|
||||
|
||||
Self::emit_update_event(uid, false);
|
||||
feat::update_profile(uid, None, is_current, false, false).await
|
||||
},
|
||||
|result| match result {
|
||||
Ok(_) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Timer,
|
||||
"Timer task completed for uid: {} (took {}ms)",
|
||||
uid,
|
||||
task_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
|
||||
},
|
||||
|| Self::emit_update_event(uid, false),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_until_resolve_done(max_wait: Duration) {
|
||||
@@ -431,8 +448,31 @@ impl Timer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Timer;
|
||||
use super::{Timer, run_timer_profile_update_transition};
|
||||
use crate::config::{PrfItem, PrfOption};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::Poll,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
struct CancellationProbe {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
completed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for CancellationProbe {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed.load(Ordering::Acquire) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_profile(uid: &str, allow_auto_update: Option<bool>, update_interval: Option<u64>) -> PrfItem {
|
||||
PrfItem {
|
||||
@@ -463,4 +503,65 @@ mod tests {
|
||||
assert_eq!(map.get("enabled"), Some(&30));
|
||||
assert_eq!(map.get("missing-flag"), Some(&30));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn timer_profile_update_runs_past_former_deadline_and_pairs_events() -> anyhow::Result<()> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let update_started = Arc::new(Barrier::new(2));
|
||||
let release_update = Arc::new(Barrier::new(2));
|
||||
let update_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let update_completed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let mut update = Box::pin(run_timer_profile_update_transition(
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || calls.lock().push("update-started")
|
||||
},
|
||||
{
|
||||
let update_started = Arc::clone(&update_started);
|
||||
let release_update = Arc::clone(&release_update);
|
||||
let update_cancelled = Arc::clone(&update_cancelled);
|
||||
let update_completed = Arc::clone(&update_completed);
|
||||
move || async move {
|
||||
let _probe = CancellationProbe {
|
||||
cancelled: update_cancelled,
|
||||
completed: Arc::clone(&update_completed),
|
||||
};
|
||||
update_started.wait().await;
|
||||
release_update.wait().await;
|
||||
update_completed.store(true, Ordering::Release);
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move |result: anyhow::Result<()>| {
|
||||
assert!(result.is_ok());
|
||||
calls.lock().push("terminal-result");
|
||||
}
|
||||
},
|
||||
{
|
||||
let calls = Arc::clone(&calls);
|
||||
move || calls.lock().push("update-completed")
|
||||
},
|
||||
));
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
update_started.wait().await;
|
||||
tokio::time::advance(Duration::from_secs(41)).await;
|
||||
|
||||
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(&*calls.lock(), &["update-started"]);
|
||||
|
||||
release_update.wait().await;
|
||||
update.await;
|
||||
assert!(update_completed.load(Ordering::Acquire));
|
||||
assert!(!update_cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
&*calls.lock(),
|
||||
&["update-started", "terminal-result", "update-completed"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::config::{IProfilePreview, IVerge};
|
||||
use crate::core::service;
|
||||
use crate::core::tray::menu_def::TrayAction;
|
||||
use crate::module::lightweight;
|
||||
use crate::process::AsyncHandler;
|
||||
@@ -15,7 +14,6 @@ use crate::{
|
||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
||||
use clash_verge_logging::logging_error;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
use tauri_plugin_mihomo::models::Proxies;
|
||||
use tokio::fs;
|
||||
|
||||
@@ -208,8 +206,7 @@ impl Tray {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
|
||||
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
|
||||
let tun_mode_available =
|
||||
is_current_app_handle_admin(app_handle) || service::is_service_available().await.is_ok();
|
||||
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
|
||||
let mode = {
|
||||
Config::clash()
|
||||
.await
|
||||
@@ -612,12 +609,10 @@ async fn create_tray_menu(
|
||||
|
||||
// TODO: should update tray menu again when it was timeout error
|
||||
let (proxy_nodes_data, runtime_proxy_groups_order) = if options.include_proxy_groups {
|
||||
let proxy_nodes_data = tokio::time::timeout(
|
||||
Duration::from_millis(1000),
|
||||
handle::Handle::mihomo().await.get_proxies(),
|
||||
)
|
||||
.await
|
||||
.map_or(None, |res| res.ok());
|
||||
let proxy_nodes_data =
|
||||
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
|
||||
.await
|
||||
.map_or(None, |res| res.ok());
|
||||
|
||||
let runtime_proxy_groups_order = cmd::get_runtime_config()
|
||||
.await
|
||||
@@ -976,7 +971,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
feat::toggle_tun_mode(None).await;
|
||||
}
|
||||
MenuIds::CLOSE_ALL_CONNECTIONS => {
|
||||
if let Err(err) = handle::Handle::mihomo().await.close_all_connections().await {
|
||||
if let Err(err) = handle::Handle::mihomo().close_all_connections().await {
|
||||
logging!(error, Type::Tray, "Failed to close all connections from tray: {err}");
|
||||
}
|
||||
}
|
||||
@@ -994,7 +989,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
let _ = help::open_app_latest_log();
|
||||
}
|
||||
MenuIds::CORE_LOG => {
|
||||
let _ = help::open_core_latest_log();
|
||||
let _ = help::open_core_latest_log().await;
|
||||
}
|
||||
MenuIds::RESTART_CLASH => feat::restart_clash_core().await,
|
||||
MenuIds::RESTART_APP => feat::restart_app().await,
|
||||
|
||||
@@ -6,7 +6,7 @@ use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri_plugin_mihomo::models::ConnectionId;
|
||||
use tauri_plugin_mihomo::models::WsConnectionId;
|
||||
|
||||
/// 托盘速率流异常后的重连间隔。
|
||||
const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
@@ -19,7 +19,7 @@ const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[derive(Clone)]
|
||||
pub struct TraySpeedController {
|
||||
speed_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
speed_connection_id: Arc<Mutex<Option<ConnectionId>>>,
|
||||
speed_connection_id: Arc<Mutex<Option<WsConnectionId>>>,
|
||||
}
|
||||
|
||||
impl Default for TraySpeedController {
|
||||
@@ -162,17 +162,17 @@ impl TraySpeedController {
|
||||
}
|
||||
|
||||
fn set_speed_connection_id(
|
||||
speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>,
|
||||
connection_id: Option<ConnectionId>,
|
||||
speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>,
|
||||
connection_id: Option<WsConnectionId>,
|
||||
) {
|
||||
*speed_connection_id.lock() = connection_id;
|
||||
}
|
||||
|
||||
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) -> Option<ConnectionId> {
|
||||
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>) -> Option<WsConnectionId> {
|
||||
speed_connection_id.lock().take()
|
||||
}
|
||||
|
||||
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) {
|
||||
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>) {
|
||||
if let Some(connection_id) = Self::take_speed_connection_id(speed_connection_id) {
|
||||
connections_stream::disconnect_connection(connection_id).await;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,21 @@ fn version_lte(a: &str, b: &str) -> bool {
|
||||
true // equal
|
||||
}
|
||||
|
||||
/// Map the app's resolved UI language code to an NSIS installer language ID.
|
||||
///
|
||||
/// The Windows installer only ships SimpChinese, English and Russian
|
||||
/// (`tauri.windows.conf.json` `nsis.languages`). Languages without a matching
|
||||
/// NSIS translation fall back to English; Traditional Chinese ("zhtw") is
|
||||
/// mapped to SimpChinese so Chinese users don't get an English installer.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn nsis_language_id(app_language: &str) -> &'static str {
|
||||
match app_language {
|
||||
"zh" | "zhtw" => "2052", // SimpChinese
|
||||
"ru" => "1049", // Russian
|
||||
_ => "1033", // English
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Startup Install & Cache Management ─────────────────────────────────────
|
||||
|
||||
impl SilentUpdater {
|
||||
@@ -186,7 +201,20 @@ impl SilentUpdater {
|
||||
|
||||
// Need a fresh Update object from the server to call install().
|
||||
// This is a lightweight HTTP request (< 1s), not a re-download.
|
||||
let update = match app_handle.updater() {
|
||||
//
|
||||
// The updater is built with the app's current language forwarded as
|
||||
// `/LANG=<NSIS-lang-id>`. On Windows the NSIS installer parses this in
|
||||
// `.onInit` (see `packages/windows/installer.nsi`) and sets `$LANGUAGE`
|
||||
// directly, skipping its language-selection dialog so the update starts
|
||||
// without prompting. Other platforms don't run an installer.
|
||||
let updater_builder = app_handle.updater_builder();
|
||||
#[cfg(target_os = "windows")]
|
||||
let updater_builder = {
|
||||
let verge_lang = Config::verge().await.latest_arc().language.clone();
|
||||
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
|
||||
updater_builder.installer_arg(format!("/LANG={lang_id}"))
|
||||
};
|
||||
let update = match updater_builder.build() {
|
||||
Ok(updater) => match updater.check().await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user