mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 05:40:47 +08:00
Compare commits
23
Commits
v2.5.4-rc.2
...
v2.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e3f1ac8a | ||
|
|
758300ac34 | ||
|
|
659ee1a181 | ||
|
|
fd06fbb3b7 | ||
|
|
ed0b801573 | ||
|
|
ff1ce92fac | ||
|
|
6a85d0344e | ||
|
|
bffe91db5e | ||
|
|
93b7bb8fe8 | ||
|
|
3b958acb22 | ||
|
|
d374ab4350 | ||
|
|
b7197f6f4b | ||
|
|
7bf8e1dcf5 | ||
|
|
07825afb1e | ||
|
|
d7e0126586 | ||
|
|
5059cdcb9d | ||
|
|
af0f502f98 | ||
|
|
8982525fd2 | ||
|
|
81ce247ecc | ||
|
|
b469bee352 | ||
|
|
df79ed3efb | ||
|
|
c6a4de1c6a | ||
|
|
433cd0628f |
Generated
+3
-3
@@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clash-verge"
|
||||
version = "2.5.4"
|
||||
version = "2.5.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -1223,8 +1223,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clash_verge_service_ipc"
|
||||
version = "2.7.2"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.2#cad270fe753553cfa2fd26fa5c338eabd210a2d5"
|
||||
version = "2.7.3"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.3#29251d968e1a38fac1de0ebe9d6d69402bea83b4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kode-bridge",
|
||||
|
||||
@@ -105,6 +105,7 @@ windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
+18
-88
@@ -1,106 +1,36 @@
|
||||
## v2.5.4
|
||||
## v2.5.5
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
|
||||
</details>
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: على وشك الخروج
|
||||
body: Clash Verge على وشك الخروج.
|
||||
appHidden:
|
||||
title: تم إخفاء التطبيق
|
||||
body: Clash Verge يعمل في الخلفية.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Beenden steht bevor
|
||||
body: Clash Verge wird gleich beendet.
|
||||
appHidden:
|
||||
title: Anwendung ausgeblendet
|
||||
body: Clash Verge läuft im Hintergrund.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: About to Exit
|
||||
body: Clash Verge is about to exit.
|
||||
appHidden:
|
||||
title: Application Hidden
|
||||
body: Clash Verge is running in the background.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: A punto de salir
|
||||
body: Clash Verge está a punto de salir.
|
||||
appHidden:
|
||||
title: Aplicación oculta
|
||||
body: Clash Verge se está ejecutando en segundo plano.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: در آستانه خروج
|
||||
body: Clash Verge در آستانه خروج است.
|
||||
appHidden:
|
||||
title: برنامه پنهان شد
|
||||
body: Clash Verge در پسزمینه در حال اجراست.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Akan Keluar
|
||||
body: Clash Verge akan keluar.
|
||||
appHidden:
|
||||
title: Aplikasi Disembunyikan
|
||||
body: Clash Verge berjalan di latar belakang.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 終了間近
|
||||
body: Clash Verge はまもなく終了します。
|
||||
appHidden:
|
||||
title: アプリが非表示
|
||||
body: Clash Verge はバックグラウンドで実行中です。
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 곧 종료
|
||||
body: Clash Verge가 곧 종료됩니다.
|
||||
appHidden:
|
||||
title: 앱이 숨겨짐
|
||||
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Скорый выход
|
||||
body: Clash Verge скоро завершит работу.
|
||||
appHidden:
|
||||
title: Приложение скрыто
|
||||
body: Clash Verge работает в фоновом режиме.
|
||||
updateReady:
|
||||
title: Обновление Clash Verge
|
||||
body: Новая версия (v{version}) скачана и готова к установке.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Çıkış Yapılmak Üzere
|
||||
body: Clash Verge kapanmak üzere.
|
||||
appHidden:
|
||||
title: Uygulama Gizlendi
|
||||
body: Clash Verge arka planda çalışıyor.
|
||||
updateReady:
|
||||
title: Clash Verge Güncellemesi
|
||||
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Чыгар алдыннан
|
||||
body: Clash Verge чыгарга җыена.
|
||||
appHidden:
|
||||
title: Кушымта яшерелде
|
||||
body: Clash Verge фон режимында эшли.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 即将退出
|
||||
body: Clash Verge 即将退出。
|
||||
appHidden:
|
||||
title: 应用已隐藏
|
||||
body: Clash Verge 正在后台运行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 即將退出
|
||||
body: Clash Verge 即將退出。
|
||||
appHidden:
|
||||
title: 應用已隱藏
|
||||
body: Clash Verge 正在背景執行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本(v{version})已下載完成,是否立即安裝?
|
||||
|
||||
@@ -1,3 +1,97 @@
|
||||
## v2.5.4
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
|
||||
|
||||
|
||||
## v2.5.2
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
|
||||
+86
-142
@@ -1,36 +1,23 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { access } 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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
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,
|
||||
)
|
||||
}
|
||||
export const developmentServiceWatchPaths = [
|
||||
'src',
|
||||
'resources',
|
||||
'Cargo.toml',
|
||||
'Cargo.lock',
|
||||
].map((name) => join(serviceRepository, name))
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
@@ -39,10 +26,15 @@ function run(command, args, options = {}) {
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
windowsHide: true,
|
||||
})
|
||||
let stdout = ''
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
resolvePromise(stdout)
|
||||
return
|
||||
}
|
||||
const error = new Error(
|
||||
@@ -54,134 +46,86 @@ function run(command, args, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
} = {}) {
|
||||
export async function prepareDevelopmentService() {
|
||||
await access(serviceManifest)
|
||||
await run('cargo', [
|
||||
'build',
|
||||
'--manifest-path',
|
||||
serviceManifest,
|
||||
'--features',
|
||||
'standalone,client,development-channel',
|
||||
'--bins',
|
||||
])
|
||||
const output = await run(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
serviceManifest,
|
||||
'--target-dir',
|
||||
process.env.CARGO_TARGET_DIR ||
|
||||
join(serviceRepository, 'target', 'development'),
|
||||
'--features',
|
||||
'standalone,client,development-channel',
|
||||
'--bins',
|
||||
'--message-format=json-render-diagnostics',
|
||||
],
|
||||
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
||||
)
|
||||
|
||||
const service = executable('clash-verge-service', platform)
|
||||
await access(service)
|
||||
return dirname(service)
|
||||
const artifacts = new Map()
|
||||
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
||||
const artifact = JSON.parse(line)
|
||||
if (
|
||||
artifact.reason === 'compiler-artifact' &&
|
||||
resolve(artifact.manifest_path) === serviceManifest &&
|
||||
artifact.target.kind.includes('bin') &&
|
||||
artifact.executable
|
||||
) {
|
||||
artifacts.set(artifact.target.name, resolve(artifact.executable))
|
||||
}
|
||||
}
|
||||
const executables = [
|
||||
'clash-verge-service',
|
||||
'clash-verge-service-install',
|
||||
'clash-verge-service-uninstall',
|
||||
'service-integration-driver',
|
||||
].map((name) => {
|
||||
const executable = artifacts.get(name)
|
||||
if (!executable)
|
||||
throw new Error(`Cargo did not report the ${name} executable`)
|
||||
return executable
|
||||
})
|
||||
const serviceDirectory = dirname(executables[0])
|
||||
for (const executable of executables) {
|
||||
if (dirname(executable) !== serviceDirectory) {
|
||||
throw new Error(
|
||||
'Development service tools were built in different directories',
|
||||
)
|
||||
}
|
||||
await access(executable)
|
||||
}
|
||||
return serviceDirectory
|
||||
}
|
||||
|
||||
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 serviceDirectory = await prepareDevelopmentService()
|
||||
const extension = platform === 'win32' ? '.exe' : ''
|
||||
const installer = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32'
|
||||
? 'clash-verge-service-install.exe'
|
||||
: 'clash-verge-service-install',
|
||||
`clash-verge-service-install${extension}`,
|
||||
)
|
||||
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.
|
||||
}
|
||||
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
|
||||
/^host: (.+)$/m,
|
||||
)?.[1]
|
||||
if (!host) throw new Error('rustc did not report its host target')
|
||||
const args = ['--prepare-install', '--ensure']
|
||||
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
|
||||
args.push(
|
||||
'--core',
|
||||
`${name}${extension}`,
|
||||
join(
|
||||
repositoryRoot,
|
||||
'src-tauri',
|
||||
'sidecar',
|
||||
`${name}-${host}${extension}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await elevateInstaller(installer, platform)
|
||||
} catch (error) {
|
||||
if (error.exitCode !== 75) throw error
|
||||
}
|
||||
await run(driver, ['ready'])
|
||||
await writeInstalledFingerprint(fingerprint)
|
||||
await run(installer, args)
|
||||
return serviceDirectory
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './dev-control.mjs'
|
||||
import {
|
||||
developmentServiceDirectoryEnvironment,
|
||||
developmentServiceWatchPaths,
|
||||
ensureDevelopmentService,
|
||||
prepareDevelopmentService,
|
||||
} from './dev-service.mjs'
|
||||
@@ -31,6 +32,9 @@ export function buildTauriInvocation(
|
||||
env.RUSTFLAGS = '--cfg tokio_unstable'
|
||||
args.push('tokio-trace')
|
||||
}
|
||||
for (const path of developmentServiceWatchPaths) {
|
||||
args.push('--additional-watch-folders', path)
|
||||
}
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args,
|
||||
|
||||
+53
-11
@@ -1,9 +1,9 @@
|
||||
import { execFileSync, execSync } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import zlib from 'zlib'
|
||||
import { execFileSync, execSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
import AdmZip from 'adm-zip'
|
||||
import { glob } from 'glob'
|
||||
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
|
||||
const zip = new AdmZip(tempZip)
|
||||
zip
|
||||
.getEntries()
|
||||
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||
zip.extractAllTo(tempDir, true)
|
||||
if (fs.existsSync(tempDll)) {
|
||||
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
||||
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
|
||||
|
||||
function serviceFileInfo(name) {
|
||||
const ext = platform === 'win32' ? '.exe' : ''
|
||||
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
|
||||
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
|
||||
return {
|
||||
sourceFile: `${name}${ext}`,
|
||||
targetFile: `${name}${suffix}${ext}`,
|
||||
@@ -586,6 +586,41 @@ async function resolveServiceBundle() {
|
||||
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
||||
'utf8',
|
||||
)
|
||||
const serviceDependency = cargoManifest
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
|
||||
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
|
||||
if (sourcePath) {
|
||||
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
|
||||
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
manifest,
|
||||
'--target-dir',
|
||||
targetDirectory,
|
||||
'--target',
|
||||
SIDECAR_HOST,
|
||||
'--release',
|
||||
'--features',
|
||||
'standalone,client',
|
||||
'--bins',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
await fsp.mkdir(SERVICE_DIR, { recursive: true })
|
||||
for (const { sourceFile, targetPath } of files) {
|
||||
await fsp.copyFile(
|
||||
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
|
||||
targetPath,
|
||||
)
|
||||
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
|
||||
await updateHashCache(targetPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
const { archiveFile, downloadURL } = resolveServiceRelease(
|
||||
cargoManifest,
|
||||
SIDECAR_HOST,
|
||||
@@ -604,8 +639,9 @@ async function resolveServiceBundle() {
|
||||
const zip = new AdmZip(tempArchive)
|
||||
zip
|
||||
.getEntries()
|
||||
.forEach((entry) =>
|
||||
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||
.forEach(
|
||||
(entry) =>
|
||||
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||
)
|
||||
zip.extractAllTo(tempDir, true)
|
||||
} else {
|
||||
@@ -652,7 +688,7 @@ async function resolveCoreHashes() {
|
||||
.digest('hex')
|
||||
lines.push(`!define ${define} "${digest}"`)
|
||||
}
|
||||
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
|
||||
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
|
||||
log_success(`Generated ${CORE_HASHES_NSH}`)
|
||||
}
|
||||
|
||||
@@ -661,6 +697,11 @@ const resolveMmdb = () =>
|
||||
file: 'Country.mmdb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
||||
})
|
||||
const resolveASNMmdb = () =>
|
||||
resolveResource({
|
||||
file: 'ASN.mmdb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
|
||||
})
|
||||
const resolveGeosite = () =>
|
||||
resolveResource({
|
||||
file: 'geosite.dat',
|
||||
@@ -706,6 +747,7 @@ const tasks = [
|
||||
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
||||
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
|
||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
||||
{
|
||||
|
||||
@@ -14,22 +14,13 @@ async function resolveUpdater() {
|
||||
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
||||
const github = getOctokit(process.env.GITHUB_TOKEN)
|
||||
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
...options,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
})
|
||||
|
||||
const tag = tags.find((t) => t.name.startsWith('v'))
|
||||
const { data: latestRelease } =
|
||||
await github.rest.repos.getLatestRelease(options)
|
||||
const tag = { name: latestRelease.tag_name }
|
||||
|
||||
console.log(tag)
|
||||
console.log()
|
||||
|
||||
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
|
||||
...options,
|
||||
tag: tag.name,
|
||||
})
|
||||
|
||||
const updateData = {
|
||||
name: tag.name,
|
||||
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
||||
|
||||
+3
-2
@@ -40,10 +40,11 @@ async function resolveUpdater() {
|
||||
const tags = allTags
|
||||
console.log(`Retrieved ${tags.length} tags in total`)
|
||||
|
||||
const stableTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
||||
|
||||
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
|
||||
const { data: latestRelease } =
|
||||
await github.rest.repos.getLatestRelease(options)
|
||||
const stableTag = { name: latestRelease.tag_name }
|
||||
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
|
||||
|
||||
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.4"
|
||||
version = "2.5.5"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -99,7 +99,7 @@ tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.2", version = "2.7.2", features = [
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.3", version = "2.7.3", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
|
||||
@@ -133,8 +133,12 @@ pub fn take_dns_override_notice() -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||
feat::set_dns_override(enabled, confirmation)
|
||||
pub async fn set_dns_override(
|
||||
profile_uid: String,
|
||||
enabled: bool,
|
||||
confirmation: Option<String>,
|
||||
) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||
feat::set_dns_override(profile_uid, enabled, confirmation)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
||||
}
|
||||
@@ -145,16 +149,26 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
if !dns_path.exists() {
|
||||
logging!(warn, Type::Config, "DNS config file not found");
|
||||
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
|
||||
return Err("DNS config file not found".into());
|
||||
}
|
||||
|
||||
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
||||
logging!(error, Type::Config, "Failed to read DNS config: {e}");
|
||||
logging!(
|
||||
error,
|
||||
Type::Config,
|
||||
"Failed to read DNS config {}: {e}",
|
||||
dns_path.display()
|
||||
);
|
||||
})?;
|
||||
|
||||
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
|
||||
logging!(
|
||||
error,
|
||||
Type::Config,
|
||||
"Failed to parse DNS config {}: {e}",
|
||||
dns_path.display()
|
||||
);
|
||||
})?;
|
||||
|
||||
let mut patch = serde_yaml_ng::Mapping::new();
|
||||
|
||||
@@ -32,6 +32,11 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
|
||||
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_discarded_keys_notice() -> Option<String> {
|
||||
crate::enhance::take_discarded_keys_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
||||
let runtime = Config::runtime().await;
|
||||
|
||||
@@ -62,6 +62,11 @@ pub fn take_service_fallback_notice() -> bool {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_repair_notice() -> bool {
|
||||
crate::core::service::take_service_repair_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_runtime_providers() {
|
||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
CoreManager,
|
||||
handle::{self, Handle},
|
||||
listener::MIXED_PORT_KEY,
|
||||
runtime_bundle::resolve_provider_path_conflicts,
|
||||
tray,
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
@@ -228,6 +229,7 @@ impl Config {
|
||||
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
|
||||
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
|
||||
|
||||
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
|
||||
sanitize_tunnels_proxy(&mut config);
|
||||
// Apply only to generated core config so the saved choice survives the next launch.
|
||||
if let Some(port) = MixedPort::session_fallback() {
|
||||
|
||||
+123
-31
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use smartstring::alias::String;
|
||||
@@ -54,6 +55,7 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DnsOverrideState {
|
||||
profile_uid: String,
|
||||
pub source: Option<String>,
|
||||
pub enabled: bool,
|
||||
requested: bool,
|
||||
@@ -61,9 +63,10 @@ pub(crate) struct DnsOverrideState {
|
||||
}
|
||||
|
||||
impl DnsOverrideState {
|
||||
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
||||
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
||||
let enabled = requested && (source.is_none() || source == confirmation);
|
||||
Self {
|
||||
profile_uid: profile_uid.into(),
|
||||
source,
|
||||
enabled,
|
||||
requested,
|
||||
@@ -72,24 +75,56 @@ impl DnsOverrideState {
|
||||
}
|
||||
|
||||
fn apply_to(&self, verge: &mut IVerge) -> bool {
|
||||
if self.profile_uid.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let settings = verge.dns_settings_for(&self.profile_uid);
|
||||
// A later settings write must not be overwritten by an earlier runtime generation.
|
||||
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|
||||
|| verge.dns_override_confirmation != self.confirmation
|
||||
{
|
||||
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
|
||||
return false;
|
||||
}
|
||||
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
|
||||
if self.enabled == self.requested && !clear_confirmation {
|
||||
if self.enabled == self.requested
|
||||
&& !clear_confirmation
|
||||
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
verge.enable_dns_settings = Some(self.enabled);
|
||||
if clear_confirmation {
|
||||
verge.dns_override_confirmation = None;
|
||||
}
|
||||
verge.profile_dns_settings.insert(
|
||||
self.profile_uid.clone(),
|
||||
ProfileDnsSettings {
|
||||
enabled: self.enabled,
|
||||
confirmation: if clear_confirmation {
|
||||
None
|
||||
} else {
|
||||
self.confirmation.clone()
|
||||
},
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProfileDnsSettings {
|
||||
pub enabled: bool,
|
||||
// Force-enable confirmation is valid only for the current app session.
|
||||
#[serde(skip)]
|
||||
pub confirmation: Option<String>,
|
||||
}
|
||||
|
||||
impl IVerge {
|
||||
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
|
||||
self.profile_dns_settings
|
||||
.get(profile_uid)
|
||||
.cloned()
|
||||
.unwrap_or(ProfileDnsSettings {
|
||||
enabled: self.enable_dns_settings.unwrap_or(false),
|
||||
confirmation: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub(crate) async fn sync_dns_override() -> Result<()> {
|
||||
let _config_write = Self::lock_config_write().await;
|
||||
@@ -116,7 +151,7 @@ impl Config {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::{DnsOverrideState, dns_override_source};
|
||||
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
|
||||
use crate::config::IVerge;
|
||||
use anyhow::Result;
|
||||
use serde_yaml_ng::Mapping;
|
||||
@@ -152,10 +187,10 @@ mod tests {
|
||||
let reordered =
|
||||
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
|
||||
assert_eq!(source, dns_override_source("one", &reordered)?);
|
||||
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
|
||||
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
|
||||
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
|
||||
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
|
||||
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
|
||||
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
|
||||
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -163,24 +198,28 @@ mod tests {
|
||||
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
|
||||
let source = Some("provider-dns".into());
|
||||
let confirmed = IVerge {
|
||||
enable_dns_settings: Some(true),
|
||||
dns_override_confirmation: source.clone(),
|
||||
profile_dns_settings: [(
|
||||
"one".into(),
|
||||
ProfileDnsSettings {
|
||||
enabled: true,
|
||||
confirmation: source.clone(),
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
..IVerge::default()
|
||||
};
|
||||
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
|
||||
let settings = confirmed.dns_settings_for("one");
|
||||
assert!(DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation).enabled);
|
||||
for saved in [
|
||||
serde_yaml_ng::to_string(&confirmed)?,
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
] {
|
||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||
let state = DnsOverrideState::new(
|
||||
source.clone(),
|
||||
restarted.enable_dns_settings.unwrap_or(false),
|
||||
restarted.dns_override_confirmation.clone(),
|
||||
);
|
||||
let settings = restarted.dns_settings_for("one");
|
||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
||||
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
||||
assert!(state.apply_to(&mut restarted));
|
||||
assert_eq!(restarted.enable_dns_settings, Some(false));
|
||||
assert!(!restarted.dns_settings_for("one").enabled);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -191,21 +230,74 @@ mod tests {
|
||||
enable_dns_settings: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
|
||||
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
|
||||
assert!(state.apply_to(&mut verge));
|
||||
assert_eq!(verge.enable_dns_settings, Some(false));
|
||||
assert!(!verge.dns_settings_for("one").enabled);
|
||||
assert!(!state.apply_to(&mut verge));
|
||||
let disabled = DnsOverrideState::new(None, false, None);
|
||||
let disabled = DnsOverrideState::new("one", None, false, None);
|
||||
assert!(!disabled.apply_to(&mut verge));
|
||||
assert!(!disabled.enabled);
|
||||
|
||||
verge.enable_dns_settings = Some(true);
|
||||
verge.dns_override_confirmation = Some("provider-dns".into());
|
||||
verge.profile_dns_settings.insert(
|
||||
"one".into(),
|
||||
ProfileDnsSettings {
|
||||
enabled: true,
|
||||
confirmation: Some("provider-dns".into()),
|
||||
},
|
||||
);
|
||||
assert!(!state.apply_to(&mut verge));
|
||||
assert_eq!(verge.enable_dns_settings, Some(true));
|
||||
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
|
||||
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
|
||||
let settings = verge.dns_settings_for("one");
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
|
||||
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
|
||||
assert!(leaving.apply_to(&mut verge));
|
||||
assert!(verge.dns_override_confirmation.is_none());
|
||||
assert!(verge.dns_settings_for("one").confirmation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
|
||||
let mut verge = IVerge {
|
||||
enable_dns_settings: Some(true),
|
||||
..IVerge::default()
|
||||
};
|
||||
let first = DnsOverrideState::new("one", None, true, None);
|
||||
assert!(first.apply_to(&mut verge));
|
||||
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
|
||||
assert!(protected.apply_to(&mut verge));
|
||||
assert!(verge.dns_settings_for("one").enabled);
|
||||
assert!(!verge.dns_settings_for("two").enabled);
|
||||
assert!(verge.dns_settings_for("unvisited").enabled);
|
||||
assert_eq!(verge.enable_dns_settings, Some(true));
|
||||
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
|
||||
assert!(restarted.dns_settings_for("one").enabled);
|
||||
assert!(!restarted.dns_settings_for("two").enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
|
||||
let mut verge = IVerge::default();
|
||||
for uid in ["one", "two"] {
|
||||
verge.profile_dns_settings.insert(
|
||||
uid.into(),
|
||||
ProfileDnsSettings {
|
||||
enabled: true,
|
||||
confirmation: Some(uid.into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
for uid in ["one", "two", "one"] {
|
||||
let settings = verge.dns_settings_for(uid);
|
||||
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
|
||||
assert!(state.enabled);
|
||||
assert!(!state.apply_to(&mut verge));
|
||||
}
|
||||
let settings = verge.dns_settings_for("two");
|
||||
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
|
||||
assert!(updated.apply_to(&mut verge));
|
||||
assert!(!verge.dns_settings_for("two").enabled);
|
||||
assert!(verge.dns_settings_for("two").confirmation.is_none());
|
||||
assert!(verge.dns_settings_for("one").enabled);
|
||||
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,14 @@ impl ProfileDeletePlan {
|
||||
return;
|
||||
};
|
||||
for file in self.files {
|
||||
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
|
||||
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
|
||||
let path = dir.join(file.as_str());
|
||||
if let Err(error) = path.remove_if_exists().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Failed to remove deleted profile file {}: {error:#}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +80,11 @@ pub struct IVerge {
|
||||
|
||||
pub enable_bypass_check: Option<bool>,
|
||||
|
||||
/// enable dns settings - this controls whether dns_config.yaml is applied
|
||||
/// Initial DNS override preference for profiles without a saved setting.
|
||||
pub enable_dns_settings: Option<bool>,
|
||||
|
||||
// Force-enable confirmation is valid only for the current app session.
|
||||
#[serde(skip)]
|
||||
pub dns_override_confirmation: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
|
||||
|
||||
pub use_default_bypass: Option<bool>,
|
||||
|
||||
|
||||
@@ -200,7 +200,6 @@ impl Hotkey {
|
||||
HotkeyFunction::Hide => {
|
||||
AsyncHandler::spawn(async move || {
|
||||
feat::hide().await;
|
||||
notify_event(NotificationEvent::AppHidden).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,54 +185,60 @@ where
|
||||
apply_proxy().await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
|
||||
is_admin: bool,
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
|
||||
start_service: Start,
|
||||
confirm_idle: Check,
|
||||
start_sidecar: Fallback,
|
||||
record_unavailable: impl FnOnce(std::string::String),
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnOnce() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
Check: FnOnce() -> CheckFuture,
|
||||
CheckFuture: std::future::Future<Output = Result<()>>,
|
||||
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
let Err(error) = start_service().await else {
|
||||
return Ok(());
|
||||
};
|
||||
// A transport failure cannot establish whether the service already started the core.
|
||||
let location_refused = error
|
||||
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| {
|
||||
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
|
||||
});
|
||||
if !is_admin || !location_refused {
|
||||
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
|
||||
let location_refused = refusal.is_some_and(|refusal| {
|
||||
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
|
||||
});
|
||||
if refusal.is_some() && !location_refused {
|
||||
return Err(error);
|
||||
}
|
||||
// A lost start response can hide a running core; the shared check must resolve occupancy.
|
||||
if let Err(probe_error) = confirm_idle().await {
|
||||
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
|
||||
record_unavailable(format!("{error:#}"));
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
|
||||
"Service could not start the core; falling back to Sidecar: {error:#}"
|
||||
);
|
||||
let reason = format!("{error:#}");
|
||||
start_sidecar(error)
|
||||
.await
|
||||
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
|
||||
.map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}")))
|
||||
}
|
||||
|
||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
|
||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
|
||||
clear_proxy: Clear,
|
||||
terminate_sidecar: Terminate,
|
||||
) -> Result<()>
|
||||
where
|
||||
Clear: FnOnce() -> ClearFuture,
|
||||
ClearFuture: std::future::Future<Output = Result<()>>,
|
||||
Terminate: FnOnce(),
|
||||
Terminate: FnOnce() -> TerminateFuture,
|
||||
TerminateFuture: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
clear_proxy().await?;
|
||||
terminate_sidecar();
|
||||
Ok(())
|
||||
terminate_sidecar().await
|
||||
}
|
||||
|
||||
async fn run_service_config_replacement_transition<
|
||||
@@ -371,6 +377,10 @@ impl CoreManager {
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||
@@ -578,6 +588,8 @@ impl CoreManager {
|
||||
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
||||
if matches!(startup, StartupDecision::Wait) {
|
||||
self.rollback_failed_start().await;
|
||||
#[cfg(target_os = "windows")]
|
||||
self.try_sidecar_after_service_unavailable().await?;
|
||||
return Ok(());
|
||||
}
|
||||
if Handle::global().is_exiting() {
|
||||
@@ -590,19 +602,12 @@ impl CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
run_service_start_with_sidecar_fallback(
|
||||
crate::core::runstate::RUN_STATE.state().is_admin,
|
||||
|| self.start_core_by_service(),
|
||||
|error| async move {
|
||||
use crate::core::runstate::RUN_STATE;
|
||||
|
||||
RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?;
|
||||
let result = self.start_core_by_sidecar().await;
|
||||
if result.is_err() {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
}
|
||||
result
|
||||
clash_verge_service_ipc::execution::check_sidecar_available,
|
||||
|error| self.start_sidecar_after_service_failure(error),
|
||||
|reason| {
|
||||
crate::core::runstate::RUN_STATE
|
||||
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -628,6 +633,50 @@ impl CoreManager {
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn try_sidecar_after_service_unavailable(&self) -> Result<()> {
|
||||
use crate::core::runstate::{RUN_STATE, ServiceHealth};
|
||||
|
||||
let state = RUN_STATE.state();
|
||||
if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() {
|
||||
return Ok(());
|
||||
}
|
||||
let reason = match state.health {
|
||||
ServiceHealth::Unavailable(reason) => reason,
|
||||
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
"Service unavailable ({reason}); Sidecar fallback withheld: {error:#}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> {
|
||||
use crate::core::runstate::RUN_STATE;
|
||||
|
||||
if Handle::global().is_exiting() {
|
||||
return Err(error.context("application exited before Sidecar fallback"));
|
||||
}
|
||||
let reason = format!("{error:#}");
|
||||
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
|
||||
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
|
||||
let result = self.start_core_by_sidecar().await;
|
||||
if result.is_err() {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
self.rollback_failed_start().await;
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
}
|
||||
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
|
||||
pub async fn stop_core(&self) -> Result<()> {
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
@@ -663,10 +712,7 @@ impl CoreManager {
|
||||
CLASH_LOGGER.clear_logs();
|
||||
match *self.get_running_mode() {
|
||||
RunningMode::Service => self.stop_core_by_service().await,
|
||||
RunningMode::Sidecar => {
|
||||
self.stop_core_by_sidecar_unprepared();
|
||||
Ok(())
|
||||
}
|
||||
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
|
||||
RunningMode::NotRunning => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -1167,9 +1213,10 @@ mod tests {
|
||||
|
||||
let result = run_sidecar_termination_transition(
|
||||
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|
||||
|| {
|
||||
|| async {
|
||||
sidecar_alive.store(false, Ordering::Release);
|
||||
calls.lock().push("handoff-sidecar-stop");
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -1449,6 +1496,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
|
||||
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
|
||||
|
||||
for idle in [false, true] {
|
||||
let store = RunStateStore::new(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let started = AtomicBool::new(false);
|
||||
let result = super::run_service_start_with_sidecar_fallback(
|
||||
|| async {
|
||||
Err(crate::core::service::ServiceStartRefusal {
|
||||
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
|
||||
core_path: "verge-mihomo.exe".into(),
|
||||
message: "no administrator-approved copy is installed".into(),
|
||||
}
|
||||
.into())
|
||||
},
|
||||
|| async move {
|
||||
anyhow::ensure!(idle, "another core is running");
|
||||
Ok(())
|
||||
},
|
||||
|_| async {
|
||||
started.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
},
|
||||
|reason| store.observe(ServiceHealth::Unavailable(reason)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(started.load(Ordering::SeqCst), idle);
|
||||
assert_eq!(result.is_ok(), idle);
|
||||
assert!(!store.state().sidecar_allowed);
|
||||
if !idle {
|
||||
assert!(store.state().service_needs_attention());
|
||||
assert!(!store.state().service_usable());
|
||||
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_service_install_can_continue_with_an_existing_sidecar() {
|
||||
assert!(can_allow_sidecar_for_session(
|
||||
|
||||
@@ -87,6 +87,7 @@ pub struct CoreManager {
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
|
||||
@@ -114,6 +115,7 @@ impl Default for CoreManager {
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
core_readiness_state: AtomicU64::new(0),
|
||||
sidecar_exit: tokio::sync::Mutex::new(None),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool::new(false),
|
||||
|
||||
@@ -11,11 +11,27 @@ use crate::{
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::Type;
|
||||
use log::Level;
|
||||
use scopeguard::defer;
|
||||
use std::path::Path;
|
||||
use tauri_plugin_mihomo::MihomoExt as _;
|
||||
use tauri_plugin_shell::ShellExt as _;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
|
||||
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
|
||||
let tun = config
|
||||
.entry(Value::from("tun"))
|
||||
.or_insert_with(|| Value::Mapping(Mapping::new()));
|
||||
if !tun.is_mapping() {
|
||||
*tun = Value::Mapping(Mapping::new());
|
||||
}
|
||||
tun.as_mapping_mut()
|
||||
.context("invalid Sidecar TUN configuration")?
|
||||
.insert(Value::from("enable"), Value::Bool(false));
|
||||
Ok(serde_yaml_ng::to_string(&config)?)
|
||||
}
|
||||
|
||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -136,12 +152,23 @@ impl CoreManager {
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
|
||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||
self.wait_for_sidecar_exit().await?;
|
||||
let execution = clash_verge_service_ipc::execution::reserve_sidecar().await?;
|
||||
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())?;
|
||||
#[cfg(target_os = "windows")]
|
||||
let config_file = if crate::core::runstate::RUN_STATE.state().is_admin {
|
||||
Config::generate_file().await?
|
||||
} else {
|
||||
// Reconciliation persists the preference later; the first spawn must already have TUN off.
|
||||
let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?;
|
||||
Config::write_runtime_file(&yaml).await?
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let config_file = Config::generate_file().await?;
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
|
||||
@@ -179,6 +206,8 @@ impl CoreManager {
|
||||
config_dir.display()
|
||||
)
|
||||
})?;
|
||||
let (terminated, termination) = tokio::sync::oneshot::channel();
|
||||
*self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination));
|
||||
#[cfg(target_os = "windows")]
|
||||
let job = {
|
||||
match create_and_assign_sidecar_job(child.pid()) {
|
||||
@@ -222,6 +251,7 @@ impl CoreManager {
|
||||
CLASH_LOGGER.append_log(message);
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
|
||||
let _ = terminated.send(());
|
||||
let manager = Self::global();
|
||||
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
|
||||
let message = if let Some(code) = term.code {
|
||||
@@ -272,10 +302,7 @@ impl CoreManager {
|
||||
|
||||
/// Terminates the sidecar after its caller has successfully cleared the
|
||||
/// system proxy.
|
||||
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
|
||||
defer! {
|
||||
self.core_stopped();
|
||||
}
|
||||
pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> {
|
||||
if let Some(child) = self.take_child_sidecar() {
|
||||
let pid = child.pid();
|
||||
|
||||
@@ -291,6 +318,22 @@ impl CoreManager {
|
||||
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
|
||||
}
|
||||
}
|
||||
let result = self.wait_for_sidecar_exit().await;
|
||||
self.core_stopped();
|
||||
result
|
||||
}
|
||||
|
||||
async fn wait_for_sidecar_exit(&self) -> Result<()> {
|
||||
let mut exit = self.sidecar_exit.lock().await;
|
||||
if let Some(task) = exit.as_mut() {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), task)
|
||||
.await
|
||||
.context("Sidecar has not exited; core handoff is blocked")?
|
||||
.context("Sidecar exit monitoring failed")?;
|
||||
exit.take();
|
||||
}
|
||||
drop(exit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(crate) mod owner_identity;
|
||||
pub mod proxy_control;
|
||||
pub mod proxy_view;
|
||||
pub mod runstate;
|
||||
mod runtime_bundle;
|
||||
pub(crate) mod runtime_bundle;
|
||||
pub mod service;
|
||||
pub mod sysopt;
|
||||
pub mod timer;
|
||||
|
||||
@@ -392,13 +392,10 @@ mod windows_owner {
|
||||
}
|
||||
|
||||
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
|
||||
// Elevated Windows processes can create directories owned by Administrators.
|
||||
std::fs::create_dir_all(root).context("failed to create the application data root before owner repair")?;
|
||||
// The Service canonicalizes before checking, so resolve junctions to the same object.
|
||||
let root = match std::fs::canonicalize(root) {
|
||||
Ok(path) => path,
|
||||
// Nothing to repair: this process creates the root and owns it by construction.
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error).context("failed to canonicalize the application data root"),
|
||||
};
|
||||
let root = std::fs::canonicalize(root).context("failed to canonicalize the application data root")?;
|
||||
let root = root.as_path();
|
||||
|
||||
let sid = current_sid()?;
|
||||
|
||||
@@ -28,7 +28,33 @@ pub struct RealEnv;
|
||||
impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
let response = clash_verge_service_ipc::get_version().await?;
|
||||
let core = if response.code == 0
|
||||
&& response.data.as_ref().is_some_and(|info| {
|
||||
info.supports_client(
|
||||
clash_verge_service_ipc::ProtocolVersion::current(),
|
||||
clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION,
|
||||
)
|
||||
}) {
|
||||
let name = format!(
|
||||
"{}{}",
|
||||
crate::config::Config::verge().await.latest_arc().get_valid_clash_core(),
|
||||
std::env::consts::EXE_SUFFIX
|
||||
);
|
||||
let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement {
|
||||
name: name.clone(),
|
||||
sha256: None,
|
||||
}])
|
||||
.await?;
|
||||
status
|
||||
.cores
|
||||
.into_iter()
|
||||
.find(|core| core.name == name)
|
||||
.map(|core| core.availability)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ServiceVersionReply {
|
||||
core,
|
||||
code: response.code,
|
||||
message: response.message,
|
||||
protocol: response.data,
|
||||
@@ -119,6 +145,7 @@ mod fake {
|
||||
#[must_use]
|
||||
pub fn service_ready(self) -> Self {
|
||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
@@ -128,6 +155,7 @@ mod fake {
|
||||
#[must_use]
|
||||
pub fn service_version_mismatch(self) -> Self {
|
||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: None,
|
||||
|
||||
@@ -152,6 +152,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
||||
self.observe(ServiceHealth::Ready);
|
||||
Ok(self.state())
|
||||
}
|
||||
ServiceVersionCheck::CoreUnavailable(error) => {
|
||||
self.observe(ServiceHealth::Unavailable(error.clone()));
|
||||
bail!(error)
|
||||
}
|
||||
ServiceVersionCheck::NeedsReinstall(error) => {
|
||||
self.observe(ServiceHealth::VersionMismatch);
|
||||
bail!(error)
|
||||
@@ -178,7 +182,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
||||
}
|
||||
|
||||
match self.env.probe_service_version().await {
|
||||
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
|
||||
Ok(reply) => match classify_service_version_reply(&reply) {
|
||||
ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason),
|
||||
_ => classify_service_health(probe_outcome(&reply), has_marker, ""),
|
||||
},
|
||||
Err(error) => {
|
||||
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
|
||||
classify_service_health(
|
||||
@@ -487,6 +494,7 @@ mod tests {
|
||||
|
||||
fn ready_reply() -> ServiceVersionReply {
|
||||
ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! 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 clash_verge_service_ipc::{
|
||||
MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion, VERSION as SERVICE_VERSION,
|
||||
};
|
||||
|
||||
use super::health::ServiceHealth;
|
||||
|
||||
@@ -14,6 +16,7 @@ pub struct ServiceVersionReply {
|
||||
pub code: u16,
|
||||
pub message: String,
|
||||
pub protocol: Option<ProtocolInfo>,
|
||||
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
|
||||
}
|
||||
|
||||
/// The verdict on a [`ServiceVersionReply`].
|
||||
@@ -21,6 +24,7 @@ pub struct ServiceVersionReply {
|
||||
pub enum ServiceVersionCheck {
|
||||
Ready,
|
||||
NeedsReinstall(String),
|
||||
CoreUnavailable(String),
|
||||
}
|
||||
|
||||
/// What a live probe of the currently-installed Service told us.
|
||||
@@ -32,22 +36,32 @@ pub enum CurrentServiceProbe {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Decide whether the Service we reached speaks a protocol this client can use.
|
||||
/// Require the bundled Service version as well as a usable protocol.
|
||||
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))
|
||||
&& reply.protocol.as_ref().is_some_and(|info| {
|
||||
info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)
|
||||
})
|
||||
{
|
||||
return ServiceVersionCheck::Ready;
|
||||
return match &reply.core {
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
|
||||
ServiceVersionCheck::CoreUnavailable(format!(
|
||||
"approved core was rejected: {reason}; reinstall the service to repair it"
|
||||
))
|
||||
}
|
||||
_ => ServiceVersionCheck::CoreUnavailable(
|
||||
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let detail = if reply.code == 0 {
|
||||
match reply.protocol.as_ref() {
|
||||
Some(info) => format!(
|
||||
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
|
||||
"client requires service version {} with epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
|
||||
SERVICE_VERSION,
|
||||
client.epoch,
|
||||
MIN_REQUIRED_SERVICE_REVISION,
|
||||
info.protocol.epoch,
|
||||
@@ -63,16 +77,16 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
|
||||
)
|
||||
};
|
||||
ServiceVersionCheck::NeedsReinstall(format!(
|
||||
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
|
||||
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
|
||||
))
|
||||
}
|
||||
|
||||
/// 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
|
||||
match classify_service_version_reply(reply) {
|
||||
ServiceVersionCheck::Ready => CurrentServiceProbe::Ready,
|
||||
ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch,
|
||||
ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +116,7 @@ mod tests {
|
||||
|
||||
fn ready_reply() -> ServiceVersionReply {
|
||||
ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
@@ -117,9 +132,26 @@ mod tests {
|
||||
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_service_with_missing_core_is_unavailable() {
|
||||
for core in [
|
||||
None,
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Missing),
|
||||
Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch),
|
||||
] {
|
||||
let reply = ServiceVersionReply { core, ..ready_reply() };
|
||||
assert!(matches!(
|
||||
classify_service_version_reply(&reply),
|
||||
ServiceVersionCheck::CoreUnavailable(_)
|
||||
));
|
||||
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_without_protocol_information_needs_reinstall() {
|
||||
let reply = ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: None,
|
||||
@@ -137,6 +169,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_nonzero_code_needs_reinstall() {
|
||||
let reply = ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 7,
|
||||
message: "nope".to_owned(),
|
||||
protocol: Some(ProtocolInfo::current()),
|
||||
@@ -152,6 +185,7 @@ mod tests {
|
||||
let mut info = ProtocolInfo::current();
|
||||
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
|
||||
let reply = ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(info),
|
||||
@@ -172,6 +206,7 @@ mod tests {
|
||||
let mut info = ProtocolInfo::current();
|
||||
info.protocol.revision = revision;
|
||||
let reply = ServiceVersionReply {
|
||||
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(info),
|
||||
@@ -190,6 +225,7 @@ mod tests {
|
||||
for protocol in [None, Some(old_epoch)] {
|
||||
for code in [0, 1] {
|
||||
let reply = ServiceVersionReply {
|
||||
core: None,
|
||||
code,
|
||||
message: "unsupported command".to_owned(),
|
||||
protocol: protocol.clone(),
|
||||
|
||||
@@ -2,11 +2,13 @@ use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use std::collections::HashSet;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub(crate) const GEO_ASSETS: &[&str] = &[
|
||||
"Country.mmdb",
|
||||
"ASN.mmdb",
|
||||
"geoip.dat",
|
||||
"geosite.dat",
|
||||
"geoip.metadb",
|
||||
@@ -53,6 +55,94 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul
|
||||
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProviderPathOwners {
|
||||
remote: BTreeMap<String, Vec<(&'static str, Value)>>,
|
||||
local: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
|
||||
let config_root = std::fs::canonicalize(config_root)?;
|
||||
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
|
||||
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
|
||||
for section in ["proxy-providers", "rule-providers"] {
|
||||
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
|
||||
continue;
|
||||
};
|
||||
for (name, provider) in providers {
|
||||
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
|
||||
let destination = if is_remote {
|
||||
provider_destination(&config_root, raw_path)
|
||||
} else {
|
||||
local_provider_source(&config_root, raw_path)
|
||||
.and_then(|source| destination_below_root(&config_root, &source))
|
||||
.or_else(|_| provider_destination(&config_root, raw_path))
|
||||
};
|
||||
let Ok(destination) = destination else {
|
||||
continue;
|
||||
};
|
||||
reserved.insert(destination.clone());
|
||||
let owners = paths.entry(destination).or_default();
|
||||
match (is_remote, provider.get("url").and_then(Value::as_str)) {
|
||||
(true, Some(url)) => owners
|
||||
.remote
|
||||
.entry(url.to_owned())
|
||||
.or_default()
|
||||
.push((section, name.clone())),
|
||||
_ => owners.local = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (destination, owners) in paths {
|
||||
if owners.local || owners.remote.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
// Allocate every source in a conflict group, so declaration order cannot select a cache's owner.
|
||||
for (url, providers) in owners.remote {
|
||||
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
|
||||
for (section, name) in providers {
|
||||
config[section][&name]["path"] = Value::String(replacement.clone());
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet<String>) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(destination.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(url.as_bytes());
|
||||
let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
let path = Path::new(destination);
|
||||
let extension = path
|
||||
.extension()
|
||||
.map(|ext| format!(".{}", ext.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut suffix = String::new();
|
||||
let mut sequence = 0;
|
||||
loop {
|
||||
let candidate = path
|
||||
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if reserved.insert(candidate.clone()) {
|
||||
return candidate;
|
||||
}
|
||||
sequence += 1;
|
||||
suffix = format!("-{sequence}");
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
|
||||
let config_root = std::fs::canonicalize(config_root)?;
|
||||
let mut assets = Vec::new();
|
||||
@@ -254,7 +344,91 @@ fn normalized_destination(relative: &Path) -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_runtime_bundle;
|
||||
use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts};
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root)?;
|
||||
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||
"rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
|
||||
)?;
|
||||
let original = config.clone();
|
||||
let root = std::fs::canonicalize(root)?;
|
||||
let mut reordered = config.clone();
|
||||
reordered["rule-providers"] = serde_yaml_ng::from_str(&format!(
|
||||
"mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
|
||||
root.join("providers/rule/AppleDev.yaml").display(),
|
||||
))?;
|
||||
|
||||
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||
let config_file = root.join("config.yaml");
|
||||
std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?;
|
||||
let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?;
|
||||
let paths = &config["rule-providers"];
|
||||
assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]);
|
||||
resolve_provider_path_conflicts(&mut reordered, &root)?;
|
||||
assert_eq!(
|
||||
config, reordered,
|
||||
"path allocation must not depend on declaration order or path spelling"
|
||||
);
|
||||
let resolved = config.clone();
|
||||
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||
assert_eq!(
|
||||
config, resolved,
|
||||
"resolving an already generated config must be idempotent"
|
||||
);
|
||||
|
||||
assert_eq!(serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&bundle.yaml)?, config);
|
||||
let readback = remote_providers_of(&config, &root)?;
|
||||
assert_eq!(bundle.remote_providers.len(), 2);
|
||||
for declared in readback {
|
||||
assert!(bundle.remote_providers.contains(&declared.provider));
|
||||
assert_eq!(
|
||||
config[declared.section][&declared.name]["path"],
|
||||
declared.provider.destination
|
||||
);
|
||||
assert_eq!(
|
||||
config[declared.section][&declared.name]["url"],
|
||||
original[declared.section][&declared.name]["url"]
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir();
|
||||
let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||
"proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n",
|
||||
)?;
|
||||
let mut config = original.clone();
|
||||
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||
let allocated = config["proxy-providers"]["a"]["path"].clone();
|
||||
let mut config = original;
|
||||
config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?;
|
||||
config["rule-providers"]["reserved"]["path"] = allocated.clone();
|
||||
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||
assert_ne!(config["proxy-providers"]["a"]["path"], allocated);
|
||||
assert_ne!(
|
||||
config["proxy-providers"]["a"]["path"],
|
||||
config["rule-providers"]["b"]["path"]
|
||||
);
|
||||
assert_eq!(config["rule-providers"]["reserved"]["path"], allocated);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> {
|
||||
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||
"rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n",
|
||||
)?;
|
||||
let original = config.clone();
|
||||
resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?;
|
||||
assert_eq!(config, original);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
|
||||
|
||||
+123
-466
@@ -27,7 +27,6 @@ use clash_verge_service_ipc::{
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
env::current_exe,
|
||||
future::Future,
|
||||
@@ -40,6 +39,7 @@ use std::{
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
@@ -51,6 +51,10 @@ pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_repair_notice() -> bool {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
#[derive(Clone)]
|
||||
@@ -482,44 +486,16 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
|
||||
Ok(sibling)
|
||||
}
|
||||
|
||||
/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_force_stop_core_shell() -> String {
|
||||
use crate::config::IVerge;
|
||||
|
||||
// 只清理 root 拥有的服务内核。
|
||||
let mut parts: Vec<String> = IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true"))
|
||||
.collect();
|
||||
|
||||
if let Ok(ipc) = dirs::ipc_path()
|
||||
&& let Ok(ipc_str) = dirs::path_to_str(&ipc)
|
||||
{
|
||||
// 转义单引号,避免破坏 shell 参数。
|
||||
let escaped = ipc_str.replace('\'', r"'\''");
|
||||
parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true"));
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn shell_single_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn macos_install_shell(install_path: &Path, gid: u32) -> String {
|
||||
let install_quoted = shell_single_quote(&install_path.to_string_lossy());
|
||||
format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}")
|
||||
}
|
||||
|
||||
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
|
||||
#[cfg(feature = "verge-dev")]
|
||||
{
|
||||
@@ -573,88 +549,6 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "install service");
|
||||
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| service_core_path(core, ".exe"))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
install_windows_service_with_cores(&cores, run_windows_service_installer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn install_windows_service_with_cores(
|
||||
cores: &[PathBuf],
|
||||
mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect();
|
||||
if cores.is_empty() {
|
||||
bail!("no core executable is available; restore a bundled core before installing the service");
|
||||
}
|
||||
let mut arguments = vec!["--install-service".into()];
|
||||
for core in cores {
|
||||
let digest = sha256_hex(core)?;
|
||||
arguments.extend([
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]);
|
||||
}
|
||||
run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> {
|
||||
use std::process::Output;
|
||||
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let token = Token::with_current_process()?;
|
||||
let level = token.privilege_level()?;
|
||||
let output = match level {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// StdCommand returns Output directly
|
||||
StdCommand::new(&install_path)
|
||||
.args(arguments)
|
||||
.creation_flags(0x08000000)
|
||||
.output()?
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn uninstall_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "uninstall service");
|
||||
@@ -706,55 +600,6 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn install_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "install service");
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let output = if linux_running_as_root() {
|
||||
StdCommand::new(&install_path).output()?
|
||||
} else {
|
||||
let result = StdCommand::new(&elevator)
|
||||
.arg("--disable-internal-agent")
|
||||
.arg(&install_path)
|
||||
.output()?;
|
||||
|
||||
// 如果 pkexec 执行失败,回退到 sudo
|
||||
if !result.status.success() && elevator.contains("pkexec") {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.status.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo").arg(&install_path).output()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_running_as_root() -> bool {
|
||||
use crate::core::handle;
|
||||
@@ -781,9 +626,8 @@ fn uninstall_service() -> Result<()> {
|
||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||
|
||||
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
|
||||
// 先清理服务残留,再执行卸载器。
|
||||
let uninstall_quoted = shell_single_quote(&uninstall_shell);
|
||||
let shell = format!("cd /; {}; {uninstall_quoted}", macos_force_stop_core_shell());
|
||||
let shell = format!("cd /; {uninstall_quoted}");
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
@@ -801,63 +645,46 @@ fn uninstall_service() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn install_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "install service");
|
||||
|
||||
let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?;
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
macos_service_tool_path(&binary_path)?;
|
||||
let install_path = macos_service_tool_path(&install_path)?;
|
||||
|
||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||
|
||||
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
|
||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||
let shell = macos_install_shell(&install_path, gid);
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
"failed to install service code: {}, details: {}",
|
||||
code,
|
||||
err
|
||||
);
|
||||
bail!("failed to install service code: {}, details: {}", code, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let executable = current_exe()?;
|
||||
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|core| {
|
||||
let name = format!("{core}{}", std::env::consts::EXE_SUFFIX);
|
||||
clash_verge_service_ipc::management::CoreSource {
|
||||
path: executable.with_file_name(&name),
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
invoke_service_install(&cores, false)
|
||||
}
|
||||
|
||||
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
|
||||
if output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.is_empty() {
|
||||
return Some((code, stderr));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if !stdout.is_empty() {
|
||||
return Some((code, stdout));
|
||||
}
|
||||
Some((code, Cow::Borrowed("Unknown error")))
|
||||
fn invoke_service_install(cores: &[clash_verge_service_ipc::management::CoreSource], core_only: bool) -> Result<()> {
|
||||
let name = format!("clash-verge-service-install{}", std::env::consts::EXE_SUFFIX);
|
||||
let installer = packaged_service_tool_path(&name, || {
|
||||
#[cfg(target_os = "linux")]
|
||||
let executable = tauri::utils::platform::current_exe()?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let executable = dirs::service_path()?;
|
||||
Ok(executable.with_file_name(&name))
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
let gid = Some(tauri_plugin_clash_verge_sysinfo::current_gid());
|
||||
#[cfg(windows)]
|
||||
let gid = None;
|
||||
clash_verge_service_ipc::management::install(
|
||||
&installer,
|
||||
cores,
|
||||
core_only,
|
||||
gid,
|
||||
&clash_verge_i18n::t!("service.adminInstallPrompt"),
|
||||
)
|
||||
}
|
||||
|
||||
fn reinstall_service() -> Result<()> {
|
||||
logging!(info, Type::Service, "reinstall service");
|
||||
// The installer replaces an existing registration and its cores in one elevation.
|
||||
install_service()
|
||||
}
|
||||
|
||||
@@ -879,158 +706,20 @@ fn force_reinstall_service() -> Result<()> {
|
||||
/// hash was computed is refused by the installer instead of published.
|
||||
pub fn stage_approved_core(core_path: &Path) -> Result<()> {
|
||||
tokio::task::block_in_place(|| {
|
||||
let digest = sha256_hex(core_path)?;
|
||||
run_core_install(core_path, &digest)
|
||||
invoke_service_install(
|
||||
&[clash_verge_service_ipc::management::CoreSource {
|
||||
name: core_path
|
||||
.file_name()
|
||||
.context("core has no filename")?
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
path: core_path.to_path_buf(),
|
||||
}],
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_hex(path: &Path) -> Result<String> {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("failed to read {path:?}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
use deelevate::{PrivilegeLevel, Token};
|
||||
use runas::Command as RunasCommand;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
use std::process::Output;
|
||||
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let token = Token::with_current_process()?;
|
||||
let output = match token.privilege_level()? {
|
||||
PrivilegeLevel::NotPrivileged => {
|
||||
let status = RunasCommand::new(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_path)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.show(false)
|
||||
.status()?;
|
||||
Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
_ => StdCommand::new(&install_path)
|
||||
.creation_flags(0x08000000)
|
||||
.arg("--install-core")
|
||||
.arg(core_path)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?,
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
|
||||
let core_argument = core_path.as_os_str();
|
||||
let output = if linux_running_as_root() {
|
||||
StdCommand::new(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?
|
||||
} else {
|
||||
let elevator = crate::utils::help::linux_elevator();
|
||||
let mut elevated = StdCommand::new(&elevator);
|
||||
// pkexec-only option; other elevators such as sudo reject unknown flags outright.
|
||||
if elevator.contains("pkexec") {
|
||||
elevated.arg("--disable-internal-agent");
|
||||
}
|
||||
let result = elevated
|
||||
.arg(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?;
|
||||
if !result.status.success() && elevator.contains("pkexec") {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"pkexec failed with code {}, falling back to sudo",
|
||||
result.status.code().unwrap_or(-1)
|
||||
);
|
||||
StdCommand::new("sudo")
|
||||
.arg(&install_path)
|
||||
.arg("--install-core")
|
||||
.arg(core_argument)
|
||||
.arg("--sha256")
|
||||
.arg(sha256_hex)
|
||||
.output()?
|
||||
} else {
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
||||
})?;
|
||||
if !install_path.exists() {
|
||||
bail!(format!("installer not found: {install_path:?}"));
|
||||
}
|
||||
let install_path = macos_service_tool_path(&install_path)?;
|
||||
|
||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
||||
let shell = format!(
|
||||
"cd /; {} --install-core {} --sha256 {}",
|
||||
shell_single_quote(&install_path.to_string_lossy()),
|
||||
shell_single_quote(&core_path.to_string_lossy()),
|
||||
shell_single_quote(sha256_hex),
|
||||
);
|
||||
let shell = escape_osascript_double_quoted_string(&shell);
|
||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||
|
||||
let output = StdCommand::new("osascript").args(["-e", &command]).output()?;
|
||||
if let Some((code, err)) = check_output_error(&output) {
|
||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatches a privileged platform operation on a blocking thread.
|
||||
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
|
||||
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
|
||||
@@ -1106,6 +795,16 @@ impl std::fmt::Display for ServiceStartRefusal {
|
||||
|
||||
impl std::error::Error for ServiceStartRefusal {}
|
||||
|
||||
fn record_service_start_refusal<E: RunStateEnv>(
|
||||
store: &RunStateStore<E>,
|
||||
refusal: ServiceStartRefusal,
|
||||
) -> anyhow::Error {
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning {
|
||||
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
|
||||
}
|
||||
refusal.into()
|
||||
}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -1140,13 +839,20 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
response.code,
|
||||
err_msg
|
||||
);
|
||||
start_owner_monitor();
|
||||
return Err(ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
#[cfg(target_os = "windows")]
|
||||
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::repair_required", "");
|
||||
}
|
||||
.into());
|
||||
start_owner_monitor();
|
||||
return Err(record_service_start_refusal(
|
||||
&RUN_STATE,
|
||||
ServiceStartRefusal {
|
||||
code: response.code,
|
||||
core_path: request.runtime.core_path,
|
||||
message: err_msg,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
||||
@@ -1166,6 +872,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -1350,8 +1057,9 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"provider cache {} was not read: {error:#}",
|
||||
declared.provider.destination
|
||||
"failed to read provider cache {} into {}: {error:#}",
|
||||
declared.provider.destination,
|
||||
temp.display()
|
||||
);
|
||||
outcome.pending += 1;
|
||||
remove_temp(&temp).await;
|
||||
@@ -1441,8 +1149,9 @@ async fn publish_fetched(
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"provider cache {} was not published: {error}",
|
||||
cache.declared.provider.destination
|
||||
"failed to rename provider cache {} to {}: {error}",
|
||||
cache.temp.display(),
|
||||
target.display()
|
||||
);
|
||||
remove_temp(&cache.temp).await;
|
||||
}
|
||||
@@ -1586,7 +1295,9 @@ fn has_settled(mtime_ns: Option<u64>) -> bool {
|
||||
// Exclusive creation protects existing files from truncation and cleanup.
|
||||
async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
||||
if let Some(parent) = target.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create provider cache directory {}", parent.display()))?;
|
||||
}
|
||||
let name = target
|
||||
.file_name()
|
||||
@@ -1958,12 +1669,13 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
||||
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
async fn wait_for_service_ipc() -> Result<()> {
|
||||
const CONTEXT: &str = "service IPC did not become available";
|
||||
let config = ServiceManager::config();
|
||||
const READY_ATTEMPTS: usize = 61;
|
||||
const READY_INTERVAL: Duration = Duration::from_millis(500);
|
||||
let span = tracing::Span::current();
|
||||
span.record("attempts", config.max_retries);
|
||||
span.record("interval_ms", config.retry_delay.as_millis() as u64);
|
||||
span.record("attempts", READY_ATTEMPTS);
|
||||
span.record("interval_ms", READY_INTERVAL.as_millis() as u64);
|
||||
|
||||
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await {
|
||||
match RUN_STATE.await_ready(READY_ATTEMPTS, READY_INTERVAL).await {
|
||||
Ok(_) => {
|
||||
tracing::Span::current().record("outcome", "ready");
|
||||
Ok(())
|
||||
@@ -2179,8 +1891,8 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
|
||||
mod tests {
|
||||
use super::{
|
||||
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
|
||||
generate_service_session_token, macos_install_shell, mark_service_unavailable_after_owner_loss,
|
||||
owner_recovery_policy, service_core_path_for, session_matches_status,
|
||||
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
|
||||
service_core_path_for, session_matches_status,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use super::{service_core_path_for_with_publisher, service_tool_path_for};
|
||||
@@ -2234,82 +1946,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_combines_service_and_attested_cores() -> anyhow::Result<()> {
|
||||
use std::ffi::OsString;
|
||||
let root = TestDirectory::new("scoop install")?;
|
||||
let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES
|
||||
.iter()
|
||||
.map(|name| root.path().join(format!("{name}.exe")))
|
||||
.collect();
|
||||
for core in &cores {
|
||||
std::fs::write(core, b"abc")?;
|
||||
}
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1, "service and cores must share one elevation");
|
||||
let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
let mut expected: Vec<OsString> = vec!["--install-service".into()];
|
||||
expected.extend(cores.iter().flat_map(|core| {
|
||||
[
|
||||
"--install-core".into(),
|
||||
core.as_os_str().to_owned(),
|
||||
"--sha256".into(),
|
||||
digest.into(),
|
||||
]
|
||||
}));
|
||||
assert_eq!(calls[0], expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_skips_unavailable_cores() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("missing-core")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
let missing = root.path().join("verge-mihomo-alpha.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let cores = [core.clone(), missing.clone(), root.path().to_path_buf()];
|
||||
let mut calls = Vec::new();
|
||||
super::install_windows_service_with_cores(&cores, |arguments| {
|
||||
calls.push(arguments.to_vec());
|
||||
Ok(())
|
||||
})?;
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].len(), 5);
|
||||
assert_eq!(calls[0][0], "--install-service");
|
||||
assert_eq!(calls[0][2], core.as_os_str());
|
||||
|
||||
let error = super::install_windows_service_with_cores(&[missing, root.path().to_path_buf()], |_| {
|
||||
panic!("no available core must fail before invoking the installer")
|
||||
})
|
||||
.expect_err("installation requires at least one available core");
|
||||
assert!(error.to_string().contains("no core executable"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_service_install_propagates_installer_failure() -> anyhow::Result<()> {
|
||||
let root = TestDirectory::new("install-failure")?;
|
||||
let core = root.path().join("verge-mihomo.exe");
|
||||
std::fs::write(&core, b"abc")?;
|
||||
let mut calls = 0;
|
||||
let result = super::install_windows_service_with_cores(std::slice::from_ref(&core), |_| {
|
||||
calls += 1;
|
||||
bail!("installer failed with exit code 1")
|
||||
});
|
||||
let error = result.expect_err("installation failure must not be reported as success");
|
||||
assert!(format!("{error:#}").contains("exit code 1"));
|
||||
assert!(error.to_string().contains("Repair"));
|
||||
assert_eq!(calls, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn staging_directory(home: &Path) -> PathBuf {
|
||||
home.join("Applications/.clash-verge-rev-dev/service-core")
|
||||
}
|
||||
@@ -2393,17 +2029,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_install_shell_starts_from_root_without_nested_sudo() {
|
||||
let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20);
|
||||
|
||||
assert_eq!(
|
||||
shell,
|
||||
"cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'"
|
||||
);
|
||||
assert!(!shell.contains("sudo"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> {
|
||||
@@ -2562,6 +2187,38 @@ mod tests {
|
||||
assert_eq!(store.generation_count(), generation + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_core_start_can_continue_with_sidecar() -> anyhow::Result<()> {
|
||||
use clash_verge_service_ipc::ServiceErrorCode;
|
||||
for code in [
|
||||
ServiceErrorCode::InvalidInstallLocation,
|
||||
ServiceErrorCode::InvalidRuntimeAsset,
|
||||
ServiceErrorCode::OwnerSwitchFailed,
|
||||
] {
|
||||
let store = fake_store();
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let error = super::record_service_start_refusal(
|
||||
&store,
|
||||
super::ServiceStartRefusal {
|
||||
code: code as u16,
|
||||
core_path: "/development/service-core/verge-mihomo".into(),
|
||||
message: "no administrator-approved copy is installed".into(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(error.downcast_ref::<super::ServiceStartRefusal>().is_some());
|
||||
let state = store.settled().await;
|
||||
assert_eq!(state.mode, crate::core::manager::RunningMode::NotRunning);
|
||||
assert!(state.service_needs_attention());
|
||||
assert!(!state.service_usable());
|
||||
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
|
||||
store.allow_sidecar_for_session()?;
|
||||
assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed);
|
||||
assert!(store.state().tun_should_be_disabled(true));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
||||
let store = fake_store();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{config::Config, singleton, utils::dirs};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use chrono::Utc;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::RwLock;
|
||||
@@ -47,17 +47,20 @@ impl SilentUpdater {
|
||||
|
||||
fn write_cache(bytes: &[u8], version: &str) -> Result<()> {
|
||||
let cache_dir = Self::cache_dir()?;
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("failed to create update cache directory {}", cache_dir.display()))?;
|
||||
|
||||
let bin_path = cache_dir.join("pending_update.bin");
|
||||
std::fs::write(&bin_path, bytes)?;
|
||||
std::fs::write(&bin_path, bytes)
|
||||
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
|
||||
|
||||
let meta = UpdateCacheMeta {
|
||||
version: version.to_string(),
|
||||
downloaded_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
let meta_path = cache_dir.join("pending_update.json");
|
||||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
|
||||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)
|
||||
.with_context(|| format!("failed to write update metadata {}", meta_path.display()))?;
|
||||
|
||||
logging!(
|
||||
info,
|
||||
@@ -71,7 +74,7 @@ impl SilentUpdater {
|
||||
|
||||
fn read_cache_bytes() -> Result<Vec<u8>> {
|
||||
let bin_path = Self::cache_dir()?.join("pending_update.bin");
|
||||
Ok(std::fs::read(bin_path)?)
|
||||
std::fs::read(&bin_path).with_context(|| format!("failed to read update cache {}", bin_path.display()))
|
||||
}
|
||||
|
||||
fn read_cache_meta() -> Result<UpdateCacheMeta> {
|
||||
@@ -85,7 +88,12 @@ impl SilentUpdater {
|
||||
&& cache_dir.exists()
|
||||
{
|
||||
if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
|
||||
logging!(warn, Type::System, "Failed to delete update cache: {e}");
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Failed to delete update cache {}: {e}",
|
||||
cache_dir.display()
|
||||
);
|
||||
} else {
|
||||
logging!(info, Type::System, "Update cache deleted");
|
||||
}
|
||||
@@ -93,15 +101,22 @@ impl SilentUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares numeric version components after stripping `v` and prerelease suffixes.
|
||||
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
||||
return false;
|
||||
};
|
||||
!build.is_empty() && !base.contains('-') && base == remote.trim_start_matches('v')
|
||||
}
|
||||
|
||||
/// Compares numeric version components after stripping version suffixes.
|
||||
fn version_lte(a: &str, b: &str) -> bool {
|
||||
let parse = |v: &str| -> Vec<u64> {
|
||||
v.trim_start_matches('v')
|
||||
.split(['-', '+'])
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.split('.')
|
||||
.filter_map(|part| {
|
||||
let numeric = part.split('-').next().unwrap_or("0");
|
||||
numeric.parse::<u64>().ok()
|
||||
})
|
||||
.filter_map(|part| part.parse::<u64>().ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
@@ -144,7 +159,7 @@ impl SilentUpdater {
|
||||
|
||||
let cached_version = &meta.version;
|
||||
|
||||
if version_lte(cached_version, current_version) {
|
||||
if !is_build_to_stable(current_version, cached_version) && version_lte(cached_version, current_version) {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
@@ -176,7 +191,7 @@ impl SilentUpdater {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Failed to read cached update bytes: {e}, cleaning up"
|
||||
"Failed to read cached update bytes: {e:#}, cleaning up"
|
||||
);
|
||||
Self::delete_cache();
|
||||
return false;
|
||||
@@ -456,7 +471,7 @@ impl SilentUpdater {
|
||||
.await?;
|
||||
|
||||
if let Err(e) = Self::write_cache(&bytes, &version) {
|
||||
logging!(warn, Type::System, "Silent updater: failed to write cache: {e}");
|
||||
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
|
||||
}
|
||||
|
||||
*self.pending_bytes.write() = Some(bytes);
|
||||
|
||||
@@ -226,7 +226,7 @@ impl CoreConfigValidator {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("Failed to read file: {err}").into();
|
||||
logging!(error, Type::Validate, "无法读取文件: {}", error_msg);
|
||||
logging!(error, Type::Validate, "Failed to read file {config_path}: {err}");
|
||||
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
||||
}
|
||||
};
|
||||
@@ -238,7 +238,7 @@ impl CoreConfigValidator {
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("YAML syntax error: {err}").into();
|
||||
logging!(error, Type::Validate, "YAML语法错误: {}", error_msg);
|
||||
logging!(error, Type::Validate, "YAML syntax error in {config_path}: {err}");
|
||||
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ impl CoreConfigValidator {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("Failed to read script file: {err}").into();
|
||||
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
|
||||
logging!(warn, Type::Validate, "Failed to read script file {path}: {err}");
|
||||
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
||||
}
|
||||
};
|
||||
@@ -291,7 +291,7 @@ impl CoreConfigValidator {
|
||||
}
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("Script syntax error: {err}").into();
|
||||
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
|
||||
logging!(warn, Type::Validate, "Script syntax error in {path}: {err}");
|
||||
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
use super::field::use_lowercase;
|
||||
use serde_yaml_ng::{self, Mapping, Value};
|
||||
|
||||
@@ -18,20 +16,19 @@ fn deep_merge(a: &mut Value, b: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_merge(merge: &Mapping, config: Mapping) -> Mapping {
|
||||
let mut config = Value::from(config);
|
||||
let merge = use_lowercase(merge);
|
||||
|
||||
deep_merge(&mut config, Value::from(merge));
|
||||
|
||||
config.as_mapping().cloned().unwrap_or_else(|| {
|
||||
logging!(
|
||||
error,
|
||||
Type::Core,
|
||||
"Failed to convert merged config to mapping, using empty mapping"
|
||||
);
|
||||
Mapping::new()
|
||||
})
|
||||
pub fn use_merge(merge: &Mapping, mut config: Mapping) -> Mapping {
|
||||
for (key, value) in use_lowercase(merge) {
|
||||
match (config.get_mut(&key), value) {
|
||||
(Some(Value::Mapping(existing)), Value::Mapping(overlay)) if key.as_str() == Some("dns") => {
|
||||
existing.extend(overlay);
|
||||
}
|
||||
(Some(existing), value) if key.as_str() != Some("hosts") => deep_merge(existing, value),
|
||||
(_, value) => {
|
||||
config.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -54,18 +51,49 @@ fn test_merge() -> anyhow::Result<()> {
|
||||
enable: true
|
||||
dns:
|
||||
enable: true
|
||||
nameserver-policy:
|
||||
new.example: 1.1.1.1
|
||||
fallback-filter:
|
||||
geoip: false
|
||||
hosts:
|
||||
new.example: 192.0.2.2
|
||||
";
|
||||
|
||||
let config = r"
|
||||
rules:
|
||||
- aaaaa
|
||||
script1: test
|
||||
tun:
|
||||
mtu: 1500
|
||||
dns:
|
||||
nameserver: [9.9.9.9]
|
||||
nameserver-policy:
|
||||
old.example: 8.8.8.8
|
||||
fallback-filter:
|
||||
geoip: true
|
||||
domain: [old.example]
|
||||
hosts:
|
||||
old.example: 192.0.2.1
|
||||
";
|
||||
|
||||
let merge = serde_yaml_ng::from_str::<Mapping>(merge)?;
|
||||
let config = serde_yaml_ng::from_str::<Mapping>(config)?;
|
||||
|
||||
let _ = serde_yaml_ng::to_string(&use_merge(&merge, config))?;
|
||||
let result = use_merge(&merge, config);
|
||||
assert_eq!(result["dns"]["nameserver-policy"], merge["dns"]["nameserver-policy"]);
|
||||
assert_eq!(result["dns"]["fallback-filter"], merge["dns"]["fallback-filter"]);
|
||||
assert_eq!(result["hosts"], merge["hosts"]);
|
||||
assert_eq!(result["dns"]["nameserver"], serde_yaml_ng::to_value(["9.9.9.9"])?);
|
||||
assert_eq!(result["tun"]["mtu"], Value::from(1500));
|
||||
assert_eq!(result["rules"], merge["rules"]);
|
||||
assert_eq!(result["script1"], Value::from("test"));
|
||||
|
||||
let cleared = serde_yaml_ng::from_str::<Mapping>(
|
||||
"dns: {enable: false, nameserver-policy: {}, fallback-filter: {}, nameserver: []}\nhosts: {}",
|
||||
)?;
|
||||
let result = use_merge(&cleared, result);
|
||||
assert_eq!(result["dns"], cleared["dns"]);
|
||||
assert_eq!(result["hosts"], cleared["hosts"]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+294
-91
@@ -14,6 +14,7 @@ use self::{
|
||||
tun::use_tun,
|
||||
};
|
||||
use crate::config::dns::{DnsOverrideState, dns_override_source};
|
||||
use crate::core::handle::Handle;
|
||||
use crate::utils::dirs;
|
||||
use crate::{
|
||||
config::{Config, IProfiles, IVerge, PrfItem},
|
||||
@@ -22,6 +23,7 @@ use crate::{
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::Mutex;
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use smartstring::alias::String;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -103,7 +105,7 @@ async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_config_values() -> ConfigValues {
|
||||
async fn get_config_values(profile_uid: &str) -> ConfigValues {
|
||||
let clash = Config::clash().await;
|
||||
let clash_arc = clash.latest_arc();
|
||||
let clash_config = clash_arc.0.clone();
|
||||
@@ -118,12 +120,12 @@ async fn get_config_values() -> ConfigValues {
|
||||
ref enable_builtin_enhanced,
|
||||
ref verge_socks_enabled,
|
||||
ref verge_http_enabled,
|
||||
ref enable_dns_settings,
|
||||
ref enable_external_controller,
|
||||
..
|
||||
} = **verge_arc;
|
||||
let enable_external_controller = enable_external_controller.unwrap_or(false);
|
||||
let dns_override_confirmation = verge_arc.dns_override_confirmation.clone();
|
||||
let dns_settings = verge_arc.dns_settings_for(profile_uid);
|
||||
let dns_override_confirmation = dns_settings.confirmation;
|
||||
|
||||
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||
Some(verge_arc.get_valid_clash_core()),
|
||||
@@ -131,7 +133,7 @@ async fn get_config_values() -> ConfigValues {
|
||||
enable_builtin_enhanced.unwrap_or(true),
|
||||
verge_socks_enabled.unwrap_or(false),
|
||||
verge_http_enabled.unwrap_or(false),
|
||||
enable_dns_settings.unwrap_or(false),
|
||||
dns_settings.enabled,
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -247,16 +249,33 @@ async fn process_global_items(
|
||||
global_merge: ChainItem,
|
||||
global_script: ChainItem,
|
||||
profile_name: &String,
|
||||
authoritative: &AuthoritativeFields,
|
||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
if let ChainType::Merge(merge) = global_merge.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
let before = authoritative.current(&config);
|
||||
config = use_merge(&merge, config);
|
||||
let notes: ResultLog = authoritative
|
||||
.overridden(&before, &authoritative.current(&config))
|
||||
.into_iter()
|
||||
.map(discarded_note)
|
||||
.collect();
|
||||
if !notes.is_empty() {
|
||||
result_map.entry(global_merge.uid).or_default().extend(notes);
|
||||
}
|
||||
}
|
||||
|
||||
if let ChainType::Script(script) = global_script.data {
|
||||
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
|
||||
let before = authoritative.current(&config);
|
||||
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||
exists_keys.extend(changed_keys);
|
||||
config = res_config;
|
||||
logs.extend(
|
||||
authoritative
|
||||
.overridden(&before, &authoritative.current(&config))
|
||||
.into_iter()
|
||||
.map(discarded_note),
|
||||
);
|
||||
result_map.insert(global_script.uid, logs);
|
||||
}
|
||||
|
||||
@@ -284,7 +303,7 @@ fn process_seq_items(
|
||||
config
|
||||
}
|
||||
|
||||
/// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
|
||||
/// App-owned control-plane keys; DNS settings are captured separately.
|
||||
const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"external-controller",
|
||||
#[cfg(unix)]
|
||||
@@ -311,23 +330,113 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
struct AuthoritativeFields {
|
||||
control_plane: Mapping,
|
||||
tun: Mapping,
|
||||
/// Only when the DNS page switched it on.
|
||||
dns_ipv6: Option<Value>,
|
||||
dns: Mapping,
|
||||
hosts: Option<Value>,
|
||||
}
|
||||
|
||||
impl AuthoritativeFields {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], mut dns_settings: Mapping) -> Self {
|
||||
Self {
|
||||
control_plane: snapshot_control_plane(config),
|
||||
tun: snapshot_tun(config, gui_tun_keys),
|
||||
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
dns: take_mapping(&mut dns_settings, "dns"),
|
||||
hosts: dns_settings.remove("hosts"),
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce(self, config: Mapping) -> Mapping {
|
||||
let config = enforce_control_plane(config, self.control_plane);
|
||||
let config = enforce_tun(config, self.tun);
|
||||
enforce_dns_ipv6(config, self.dns_ipv6)
|
||||
let mut config = enforce_tun(config, self.tun);
|
||||
if !self.dns.is_empty() {
|
||||
let mut dns = take_mapping(&mut config, "dns");
|
||||
dns.extend(self.dns);
|
||||
config.insert("dns".into(), dns.into());
|
||||
}
|
||||
if let Some(hosts) = self.hosts {
|
||||
config.insert("hosts".into(), hosts);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// The owned values as `config` holds them now, for diffing one override.
|
||||
fn current(&self, config: &Mapping) -> Self {
|
||||
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
|
||||
let dns = config.get("dns").and_then(Value::as_mapping);
|
||||
Self {
|
||||
control_plane: snapshot_control_plane(config),
|
||||
tun: snapshot_tun(config, &tun_keys),
|
||||
dns: self
|
||||
.dns
|
||||
.keys()
|
||||
.filter_map(|key| Some((key.clone(), dns?.get(key)?.clone())))
|
||||
.collect(),
|
||||
hosts: self.hosts.as_ref().and_then(|_| config.get("hosts")).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned keys an override moved off the app value; `enforce` discards those writes.
|
||||
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
for &key in CONTROL_PLANE_KEYS {
|
||||
if before.control_plane.get(key) != after.control_plane.get(key)
|
||||
&& after.control_plane.get(key) != self.control_plane.get(key)
|
||||
{
|
||||
keys.push(key.into());
|
||||
}
|
||||
}
|
||||
for key in self.tun.keys() {
|
||||
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
|
||||
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
|
||||
}
|
||||
}
|
||||
for key in self.dns.keys() {
|
||||
if before.dns.get(key) != after.dns.get(key) && after.dns.get(key) != self.dns.get(key) {
|
||||
keys.push(format!("dns.{}", key.as_str().unwrap_or_default()).into());
|
||||
}
|
||||
}
|
||||
if self.hosts.is_some() && before.hosts != after.hosts && after.hosts != self.hosts {
|
||||
keys.push("hosts".into());
|
||||
}
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
fn discarded_note(key: String) -> (String, String) {
|
||||
(
|
||||
"warn".into(),
|
||||
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
|
||||
)
|
||||
}
|
||||
|
||||
struct DiscardedKeysNotice {
|
||||
pending: Option<String>,
|
||||
last: Vec<String>,
|
||||
}
|
||||
|
||||
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
|
||||
pending: None,
|
||||
last: Vec::new(),
|
||||
});
|
||||
|
||||
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
|
||||
DISCARDED_KEYS_NOTICE.lock().pending.take()
|
||||
}
|
||||
|
||||
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
|
||||
fn notify_discarded_keys(keys: Vec<String>) {
|
||||
let mut notice = DISCARDED_KEYS_NOTICE.lock();
|
||||
if notice.last == keys {
|
||||
return;
|
||||
}
|
||||
let should_notify = !keys.is_empty();
|
||||
if should_notify {
|
||||
notice.pending = Some(keys.join(", ").into());
|
||||
}
|
||||
notice.last = keys;
|
||||
drop(notice);
|
||||
|
||||
if should_notify {
|
||||
Handle::notice_message("enhance::discarded_keys", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,21 +504,6 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// 只在 DNS 页开启 IPv6 时快照。
|
||||
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
|
||||
config.get("dns")?.get("ipv6").cloned()
|
||||
}
|
||||
|
||||
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
|
||||
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
|
||||
if let Some(dns_ipv6) = dns_ipv6
|
||||
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
|
||||
{
|
||||
dns.insert(Value::from("ipv6"), dns_ipv6);
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn is_loopback_bind_address(addr: &str) -> bool {
|
||||
let addr = addr.trim();
|
||||
let addr = addr
|
||||
@@ -459,16 +553,33 @@ async fn process_profile_items(
|
||||
merge_item: ChainItem,
|
||||
script_item: ChainItem,
|
||||
profile_name: &String,
|
||||
authoritative: &AuthoritativeFields,
|
||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||
if let ChainType::Merge(merge) = merge_item.data {
|
||||
exists_keys.extend(use_keys(&merge));
|
||||
let before = authoritative.current(&config);
|
||||
config = use_merge(&merge, config);
|
||||
let notes: ResultLog = authoritative
|
||||
.overridden(&before, &authoritative.current(&config))
|
||||
.into_iter()
|
||||
.map(discarded_note)
|
||||
.collect();
|
||||
if !notes.is_empty() {
|
||||
result_map.entry(merge_item.uid).or_default().extend(notes);
|
||||
}
|
||||
}
|
||||
|
||||
if let ChainType::Script(script) = script_item.data {
|
||||
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
|
||||
let before = authoritative.current(&config);
|
||||
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||
exists_keys.extend(changed_keys);
|
||||
config = res_config;
|
||||
logs.extend(
|
||||
authoritative
|
||||
.overridden(&before, &authoritative.current(&config))
|
||||
.into_iter()
|
||||
.map(discarded_note),
|
||||
);
|
||||
result_map.insert(script_item.uid, logs);
|
||||
}
|
||||
|
||||
@@ -708,24 +819,6 @@ fn is_set(value: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay set fields onto base; maps merge per key, lists are leaves.
|
||||
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
|
||||
for (key, value) in overlay {
|
||||
let value = match (base.get_mut(&key), value) {
|
||||
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
|
||||
*existing = merge_set_fields(std::mem::take(existing), patch);
|
||||
continue;
|
||||
}
|
||||
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
|
||||
(_, value) => value,
|
||||
};
|
||||
if is_set(&value) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||
config
|
||||
.get_mut(key)
|
||||
@@ -734,46 +827,58 @@ fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the merged config and whether the page set `dns.ipv6`.
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
|
||||
let mut owns_ipv6 = false;
|
||||
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
|
||||
let mut applied = Mapping::new();
|
||||
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
|
||||
&& !hosts.is_empty()
|
||||
{
|
||||
applied.insert("hosts".into(), hosts.clone().into());
|
||||
config.insert("hosts".into(), hosts.into());
|
||||
logging!(debug, Type::Core, "apply hosts configuration");
|
||||
}
|
||||
|
||||
// Legacy layout: no `dns` root.
|
||||
let dns_override = match dns_config.remove("dns") {
|
||||
Some(Value::Mapping(dns)) => Some(dns),
|
||||
Some(_) => None,
|
||||
None => Some(dns_config),
|
||||
};
|
||||
if let Some(mut dns_override) = dns_override {
|
||||
dns_override.retain(|_, value| is_set(value));
|
||||
if !dns_override.is_empty() {
|
||||
applied.insert("dns".into(), dns_override.clone().into());
|
||||
}
|
||||
let mut dns = take_mapping(&mut config, "dns");
|
||||
dns.extend(dns_override);
|
||||
ensure_fake_ip_range6(&mut dns);
|
||||
config.insert("dns".into(), dns.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml");
|
||||
}
|
||||
(config, applied)
|
||||
}
|
||||
|
||||
/// Returns the merged config and the non-empty fields owned by the DNS page.
|
||||
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, Mapping) {
|
||||
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
|
||||
if dns_path.exists()
|
||||
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
|
||||
&& let Ok(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
||||
&& let Ok(dns_config) = serde_yaml_ng::from_str::<Mapping>(&dns_yaml)
|
||||
{
|
||||
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
|
||||
let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
|
||||
config.insert("hosts".into(), hosts.into());
|
||||
logging!(debug, Type::Core, "apply hosts configuration");
|
||||
}
|
||||
|
||||
// Legacy layout: no `dns` root.
|
||||
let dns_override = match dns_config.remove("dns") {
|
||||
Some(Value::Mapping(dns)) => Some(dns),
|
||||
Some(_) => None,
|
||||
None => Some(dns_config),
|
||||
};
|
||||
if let Some(dns_override) = dns_override {
|
||||
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
|
||||
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
|
||||
ensure_fake_ip_range6(&mut dns);
|
||||
config.insert("dns".into(), dns.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml");
|
||||
}
|
||||
return merge_dns_config(config, dns_config);
|
||||
}
|
||||
}
|
||||
|
||||
(config, owns_ipv6)
|
||||
(config, Mapping::new())
|
||||
}
|
||||
|
||||
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
|
||||
pub async fn enhance(
|
||||
profiles: &IProfiles,
|
||||
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
|
||||
let cfg_vals = get_config_values().await;
|
||||
let profile_uid = profiles.current.as_deref().unwrap_or_default();
|
||||
let cfg_vals = get_config_values(profile_uid).await;
|
||||
let ConfigValues {
|
||||
clash_config,
|
||||
clash_core,
|
||||
@@ -792,7 +897,8 @@ pub async fn enhance(
|
||||
|
||||
let profile = collect_profile_items(profiles).await?;
|
||||
let dns_override = DnsOverrideState::new(
|
||||
dns_override_source(profiles.current.as_deref().unwrap_or_default(), &profile.config)?,
|
||||
profile_uid,
|
||||
dns_override_source(profile_uid, &profile.config)?,
|
||||
enable_dns_settings,
|
||||
dns_override_confirmation,
|
||||
);
|
||||
@@ -827,9 +933,9 @@ pub async fn enhance(
|
||||
|
||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
||||
let config = use_tun(config, enable_tun);
|
||||
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
|
||||
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
||||
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
|
||||
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
@@ -838,12 +944,22 @@ pub async fn enhance(
|
||||
global_merge,
|
||||
global_script,
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (config, exists_keys, result_map) =
|
||||
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
|
||||
let (config, exists_keys, result_map) = process_profile_items(
|
||||
config,
|
||||
exists_keys,
|
||||
result_map,
|
||||
merge_item,
|
||||
script_item,
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
|
||||
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
|
||||
let config = authoritative.enforce(config);
|
||||
let config = ensure_lan_bind_address(config);
|
||||
|
||||
@@ -1245,7 +1361,7 @@ mod authoritative_field_tests {
|
||||
#[test]
|
||||
fn an_override_cannot_change_a_field_the_app_owns() {
|
||||
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||
|
||||
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
@@ -1258,7 +1374,7 @@ mod authoritative_field_tests {
|
||||
fn an_override_cannot_introduce_a_field_the_app_left_out() {
|
||||
// The app decided not to expose the external controller; a profile must not re-add it.
|
||||
let derived = Mapping::new();
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||
|
||||
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
@@ -1269,7 +1385,7 @@ mod authoritative_field_tests {
|
||||
#[test]
|
||||
fn fields_the_app_does_not_own_survive_an_override() {
|
||||
let derived = config_with(&[("mode", Value::from("rule"))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||
|
||||
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
@@ -1281,7 +1397,7 @@ mod authoritative_field_tests {
|
||||
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
|
||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||
|
||||
let owned = AuthoritativeFields::capture(&derived, &[], true);
|
||||
let owned = AuthoritativeFields::capture(&derived, &[], derived.clone());
|
||||
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||
assert_eq!(
|
||||
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||
@@ -1289,7 +1405,7 @@ mod authoritative_field_tests {
|
||||
"with the DNS page on, the app's value wins"
|
||||
);
|
||||
|
||||
let unowned = AuthoritativeFields::capture(&derived, &[], false);
|
||||
let unowned = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||
assert_eq!(
|
||||
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||
@@ -1299,14 +1415,13 @@ mod authoritative_field_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_dns_ipv6_never_invents_a_dns_block() {
|
||||
fn restoring_dns_settings_reinstates_a_removed_dns_block() {
|
||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], true);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, &[], derived.clone());
|
||||
|
||||
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
|
||||
let result = authoritative.enforce(Mapping::new());
|
||||
|
||||
assert!(!result.contains_key(Value::from("dns")));
|
||||
assert_eq!(result["dns"], derived["dns"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1314,8 +1429,8 @@ mod authoritative_field_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
|
||||
process_profile_items, use_keys,
|
||||
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
|
||||
process_global_items, process_profile_items, use_keys,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -1323,6 +1438,89 @@ mod tests {
|
||||
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dns_settings_override_only_filled_fields_after_merge_and_script() {
|
||||
let profile = mapping(
|
||||
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
|
||||
hosts: {profile.example: 192.0.2.1}",
|
||||
);
|
||||
let settings = mapping(
|
||||
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8},\n\
|
||||
proxy-server-nameserver: [], fallback: null, listen: ' '}\n\
|
||||
hosts: {settings.example: 192.0.2.2}",
|
||||
);
|
||||
let merge = mapping(
|
||||
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
|
||||
hosts: {merge.example: 192.0.2.3}",
|
||||
);
|
||||
let script = r#"function main(config) {
|
||||
config.dns = {
|
||||
ipv6: false,
|
||||
nameserver: ["4.4.4.4"],
|
||||
"nameserver-policy": {"script.example": "4.4.4.4"},
|
||||
"proxy-server-nameserver": ["4.4.4.4"],
|
||||
fallback: ["4.4.4.4"],
|
||||
listen: "127.0.0.1:1053"
|
||||
};
|
||||
config.hosts = {"script.example": "192.0.2.4"};
|
||||
return config;
|
||||
}"#;
|
||||
|
||||
for enabled in [false, true] {
|
||||
let (config, dns_settings) = if enabled {
|
||||
super::merge_dns_config(profile.clone(), settings.clone())
|
||||
} else {
|
||||
(profile.clone(), Mapping::new())
|
||||
};
|
||||
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
|
||||
let merge_item = |uid: &str| ChainItem {
|
||||
uid: uid.into(),
|
||||
data: ChainType::Merge(merge.clone()),
|
||||
};
|
||||
let profile_name = "test-profile".into();
|
||||
let (config, keys, logs) = process_global_items(
|
||||
config,
|
||||
vec![],
|
||||
HashMap::new(),
|
||||
merge_item("global-merge"),
|
||||
ChainItem::to_script("global-script", script),
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
let (config, _, logs) = process_profile_items(
|
||||
config,
|
||||
keys,
|
||||
logs,
|
||||
merge_item("profile-merge"),
|
||||
ChainItem::to_script("profile-script", script),
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
let manual = config.clone();
|
||||
let result = authoritative.enforce(config);
|
||||
|
||||
let source = if enabled { &settings } else { &manual };
|
||||
assert_eq!(result["dns"]["nameserver-policy"], source["dns"]["nameserver-policy"]);
|
||||
assert_eq!(result["hosts"], source["hosts"]);
|
||||
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
|
||||
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
|
||||
for key in ["proxy-server-nameserver", "fallback", "listen"] {
|
||||
assert_eq!(result["dns"][key], manual["dns"][key]);
|
||||
}
|
||||
if enabled {
|
||||
for uid in ["global-merge", "global-script", "profile-merge", "profile-script"] {
|
||||
assert!(
|
||||
logs[uid]
|
||||
.iter()
|
||||
.any(|(_, message)| message.contains("dns.nameserver-policy"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_overrides_follow_expected_priority() {
|
||||
let mut config = mapping(
|
||||
@@ -1367,6 +1565,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let profile_name = "test-profile".into();
|
||||
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
exists_keys,
|
||||
@@ -1374,6 +1573,7 @@ mod tests {
|
||||
global_merge,
|
||||
global_script,
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
let (config, exists_keys, _) = process_profile_items(
|
||||
@@ -1383,6 +1583,7 @@ mod tests {
|
||||
profile_merge,
|
||||
profile_script,
|
||||
&profile_name,
|
||||
&authoritative,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1520,19 +1721,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
|
||||
fn empty_dns_settings_leave_inherited_fields_overridable() {
|
||||
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
|
||||
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
|
||||
let (config, dns_settings) =
|
||||
super::merge_dns_config(app_config, mapping("dns: {ipv6: false, proxy-server-nameserver: []}"));
|
||||
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
|
||||
|
||||
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
|
||||
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
|
||||
let result = authoritative.enforce(hijacked);
|
||||
|
||||
assert_eq!(
|
||||
result
|
||||
.get("dns")
|
||||
.and_then(|value| value.get("ipv6"))
|
||||
.and_then(serde_yaml_ng::Value::as_bool),
|
||||
Some(false)
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, ensure};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use serde::Serialize;
|
||||
use smartstring::alias::String;
|
||||
|
||||
use crate::{
|
||||
config::{Config, dns::dns_override_source, profiles::PROFILE_WRITE_LOCK},
|
||||
config::{
|
||||
Config,
|
||||
dns::{ProfileDnsSettings, dns_override_source},
|
||||
profiles::PROFILE_WRITE_LOCK,
|
||||
},
|
||||
constants::files::DNS_CONFIG,
|
||||
core::{CoreManager, handle::Handle},
|
||||
utils::dirs,
|
||||
@@ -17,10 +21,18 @@ pub enum DnsOverrideOutcome {
|
||||
ConfirmationRequired { source: String },
|
||||
}
|
||||
|
||||
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Result<DnsOverrideOutcome> {
|
||||
pub async fn set_dns_override(
|
||||
profile_uid: String,
|
||||
enabled: bool,
|
||||
confirmation: Option<String>,
|
||||
) -> Result<DnsOverrideOutcome> {
|
||||
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
|
||||
let _config_write = Config::lock_config_write().await;
|
||||
let profiles = Config::profiles().await.data_arc();
|
||||
ensure!(
|
||||
profiles.current.as_ref() == Some(&profile_uid),
|
||||
"The active profile changed; retry the DNS setting"
|
||||
);
|
||||
let source = dns_override_source(
|
||||
profiles.current.as_deref().unwrap_or_default(),
|
||||
&profiles.current_mapping().await?,
|
||||
@@ -37,8 +49,13 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
|
||||
let verge = Config::verge().await;
|
||||
let transaction = DraftTransaction::begin(vec![&verge])?;
|
||||
verge.edit_draft(|draft| {
|
||||
draft.enable_dns_settings = Some(enabled);
|
||||
draft.dns_override_confirmation = if enabled { source } else { None };
|
||||
draft.profile_dns_settings.insert(
|
||||
profile_uid.clone(),
|
||||
ProfileDnsSettings {
|
||||
enabled,
|
||||
confirmation: if enabled { source } else { None },
|
||||
},
|
||||
);
|
||||
});
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
|
||||
@@ -47,8 +64,9 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
|
||||
// A subscription update can replace the file while Mihomo is validating it.
|
||||
if enabled && let Some(state) = state.filter(|state| !state.enabled) {
|
||||
verge.edit_draft(|draft| {
|
||||
draft.enable_dns_settings = Some(false);
|
||||
draft.dns_override_confirmation = None;
|
||||
draft
|
||||
.profile_dns_settings
|
||||
.insert(profile_uid, ProfileDnsSettings::default());
|
||||
});
|
||||
if let Some(source) = state.source {
|
||||
outcome = DnsOverrideOutcome::ConfirmationRequired { source };
|
||||
|
||||
+10
-1
@@ -44,7 +44,14 @@ mod app_init {
|
||||
let mut builder = builder
|
||||
.plugin(tauri_plugin_clash_verge_sysinfo::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.default_version_comparator(|current, release| {
|
||||
release.version > current
|
||||
|| core::updater::is_build_to_stable(¤t.to_string(), &release.version.to_string())
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
@@ -164,6 +171,8 @@ mod app_init {
|
||||
cmd::set_dns_override,
|
||||
cmd::take_dns_override_notice,
|
||||
cmd::take_service_fallback_notice,
|
||||
cmd::take_service_repair_notice,
|
||||
cmd::take_discarded_keys_notice,
|
||||
cmd::get_dns_config_content,
|
||||
cmd::validate_dns_config,
|
||||
cmd::get_clash_logs,
|
||||
|
||||
@@ -16,9 +16,13 @@ pub async fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
||||
bail!("file not found \"{}\"", path.display());
|
||||
}
|
||||
|
||||
let yaml_str = tokio::fs::read_to_string(path).await?;
|
||||
let yaml_str = tokio::fs::read_to_string(path)
|
||||
.await
|
||||
.with_context(|| format!("failed to read YAML file {}", path.display()))?;
|
||||
|
||||
Ok(with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) }).await?)
|
||||
with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) })
|
||||
.await
|
||||
.with_context(|| format!("failed to parse YAML file {}", path.display()))
|
||||
}
|
||||
|
||||
pub async fn read_mapping(path: &Path) -> Result<Mapping> {
|
||||
|
||||
+31
-12
@@ -9,7 +9,7 @@ use crate::{
|
||||
help,
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use chrono::{Local, TimeZone as _};
|
||||
use clash_verge_logging::Type;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
@@ -28,8 +28,15 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
|
||||
];
|
||||
|
||||
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
|
||||
let mut entries = fs::read_dir(temp_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let mut entries = fs::read_dir(temp_dir)
|
||||
.await
|
||||
.with_context(|| format!("failed to read log snapshot directory {}", temp_dir.display()))?;
|
||||
while let Some(entry) = entries.next_entry().await.with_context(|| {
|
||||
format!(
|
||||
"failed to read next entry in log snapshot directory {}",
|
||||
temp_dir.display()
|
||||
)
|
||||
})? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("log") {
|
||||
let _ = path.remove_if_exists().await;
|
||||
@@ -109,15 +116,28 @@ pub async fn delete_log() -> Result<()> {
|
||||
};
|
||||
|
||||
if log_dir.exists() {
|
||||
let mut log_read_dir = fs::read_dir(&log_dir).await?;
|
||||
while let Some(entry) = log_read_dir.next_entry().await? {
|
||||
let mut log_read_dir = fs::read_dir(&log_dir)
|
||||
.await
|
||||
.with_context(|| format!("failed to read log directory {}", log_dir.display()))?;
|
||||
while let Some(entry) = log_read_dir
|
||||
.next_entry()
|
||||
.await
|
||||
.with_context(|| format!("failed to read next entry in log directory {}", log_dir.display()))?
|
||||
{
|
||||
std::mem::drop(process_file(entry).await);
|
||||
}
|
||||
}
|
||||
|
||||
if service_log_dir.exists() {
|
||||
let mut service_log_read_dir = fs::read_dir(service_log_dir).await?;
|
||||
while let Some(entry) = service_log_read_dir.next_entry().await? {
|
||||
let mut service_log_read_dir = fs::read_dir(&service_log_dir)
|
||||
.await
|
||||
.with_context(|| format!("failed to read service log directory {}", service_log_dir.display()))?;
|
||||
while let Some(entry) = service_log_read_dir.next_entry().await.with_context(|| {
|
||||
format!(
|
||||
"failed to read next entry in service log directory {}",
|
||||
service_log_dir.display()
|
||||
)
|
||||
})? {
|
||||
std::mem::drop(process_file(entry).await);
|
||||
}
|
||||
}
|
||||
@@ -496,7 +516,7 @@ pub async fn init_resources() -> Result<()> {
|
||||
std::mem::drop(fs::create_dir_all(&res_dir).await);
|
||||
}
|
||||
|
||||
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
|
||||
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
|
||||
|
||||
for file in file_list.iter() {
|
||||
let src_path = res_dir.join(file);
|
||||
@@ -624,10 +644,9 @@ async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) {
|
||||
logging!(
|
||||
error,
|
||||
Type::Setup,
|
||||
"failed to copy resources '{}' to '{:?}', {}",
|
||||
file,
|
||||
dest,
|
||||
err
|
||||
"failed to copy resource {} to {}: {err}",
|
||||
src.display(),
|
||||
dest.display()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,8 +18,6 @@ pub enum NotificationEvent<'a> {
|
||||
LightweightModeEntered,
|
||||
ProfilesReactivated,
|
||||
AppQuit,
|
||||
#[cfg(target_os = "macos")]
|
||||
AppHidden,
|
||||
}
|
||||
|
||||
/// Whether no app window can currently show the failure.
|
||||
@@ -95,11 +93,5 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
|
||||
let body = clash_verge_i18n::t!("notifications.appQuit.body");
|
||||
notify(title, body);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
NotificationEvent::AppHidden => {
|
||||
let title = clash_verge_i18n::t!("notifications.appHidden.title");
|
||||
let body = clash_verge_i18n::t!("notifications.appHidden.body");
|
||||
notify(title, body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use anyhow::Context as _;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::Path;
|
||||
@@ -9,7 +10,7 @@ const DNS_STATE_FILE: &str = ".original_dns.txt";
|
||||
fn dns_state_dir() -> anyhow::Result<PathBuf> {
|
||||
// The DNS scripts persist .original_dns.txt relative to their working directory.
|
||||
let dir = crate::utils::dirs::app_home_dir()?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
@@ -37,14 +38,14 @@ pub async fn set_public_dns(dns_server: String) {
|
||||
};
|
||||
let script = resource_dir.join("set_dns.sh");
|
||||
if !script.exists() {
|
||||
logging!(error, Type::Config, "set_dns.sh not found");
|
||||
logging!(error, Type::Config, "DNS script not found: {}", script.display());
|
||||
return;
|
||||
}
|
||||
let script = script.to_string_lossy().into_owned();
|
||||
let state_dir = match dns_state_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
|
||||
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -85,14 +86,14 @@ pub async fn restore_public_dns() {
|
||||
};
|
||||
let script = resource_dir.join("unset_dns.sh");
|
||||
if !script.exists() {
|
||||
logging!(error, Type::Config, "unset_dns.sh not found");
|
||||
logging!(error, Type::Config, "DNS script not found: {}", script.display());
|
||||
return;
|
||||
}
|
||||
let script = script.to_string_lossy().into_owned();
|
||||
let state_dir = match dns_state_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
|
||||
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::utils::dirs::{self, PathBufExec as _};
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
@@ -100,8 +100,14 @@ async fn cleanup_legacy_shortcuts() -> Result<()> {
|
||||
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
|
||||
let new_shortcut = startup_dir.join("Clash Verge.lnk");
|
||||
|
||||
old_shortcut.remove_if_exists().await?;
|
||||
new_shortcut.remove_if_exists().await?;
|
||||
old_shortcut
|
||||
.remove_if_exists()
|
||||
.await
|
||||
.with_context(|| format!("failed to remove startup shortcut {}", old_shortcut.display()))?;
|
||||
new_shortcut
|
||||
.remove_if_exists()
|
||||
.await
|
||||
.with_context(|| format!("failed to remove startup shortcut {}", new_shortcut.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -327,7 +333,7 @@ pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
|
||||
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
|
||||
|
||||
if let Err(err) = cleanup_legacy_shortcuts().await {
|
||||
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
|
||||
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {err:#}");
|
||||
}
|
||||
|
||||
if is_enable {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub(crate) fn report_error(error: &anyhow::Error) {
|
||||
eprintln!("[clash-verge] startup failed: {detail}");
|
||||
|
||||
let log_result = startup_log_path().and_then(|path| {
|
||||
append_error(&path, &detail)?;
|
||||
append_error(&path, &detail).with_context(|| format!("startup log {}", path.display()))?;
|
||||
Ok(path)
|
||||
});
|
||||
let message = match log_result {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AdminPanelSettingsOutlined,
|
||||
DnsOutlined,
|
||||
ExtensionOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
@@ -29,7 +30,8 @@ export const SystemInfoCard = () => {
|
||||
const { t } = useTranslation()
|
||||
const { verge, patchVerge } = useVerge()
|
||||
const navigate = useNavigate()
|
||||
const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState()
|
||||
const { runningMode, isAdminMode, isSidecarMode, mutateSystemState } =
|
||||
useSystemState()
|
||||
const { installServiceAndRestartCore } = useServiceInstaller()
|
||||
|
||||
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
|
||||
@@ -135,6 +137,14 @@ export const SystemInfoCard = () => {
|
||||
)
|
||||
|
||||
const getModeIcon = () => {
|
||||
if (runningMode === 'NotRunning') {
|
||||
return (
|
||||
<PauseCircleOutlined
|
||||
sx={{ color: 'text.secondary', fontSize: 16 }}
|
||||
titleAccess={t('home.components.systemInfo.badges.notRunning')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (isAdminMode) {
|
||||
if (!isSidecarMode) {
|
||||
return (
|
||||
@@ -174,6 +184,9 @@ export const SystemInfoCard = () => {
|
||||
}
|
||||
|
||||
const getModeText = () => {
|
||||
if (runningMode === 'NotRunning') {
|
||||
return t('home.components.systemInfo.badges.notRunning')
|
||||
}
|
||||
if (isAdminMode) {
|
||||
if (!isSidecarMode) {
|
||||
return t('home.components.systemInfo.badges.adminServiceMode')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RunState } from '@/services/cmds'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
|
||||
import { ServiceMigrationDialog } from './service-migration-dialog'
|
||||
|
||||
vi.mock('react', () => ({ useState: vi.fn() }))
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
vi.mock('@mui/material', () => ({ Alert: 'div' }))
|
||||
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
|
||||
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
|
||||
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||
vi.mock('@/services/cmds', () => ({ getRuntimeState: vi.fn() }))
|
||||
vi.mock('@/services/notice-service', () => ({ showNotice: {} }))
|
||||
vi.mock('@/services/query-client', () => ({
|
||||
useQuery: vi.fn(),
|
||||
setCacheData: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useState)
|
||||
.mockReturnValueOnce([false, vi.fn()])
|
||||
.mockReturnValueOnce([false, vi.fn()])
|
||||
.mockReturnValueOnce([true, vi.fn()])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ service: 'ready', mode: 'NotRunning' },
|
||||
{ service: 'unknown', mode: 'NotRunning' },
|
||||
{ service: 'unavailable', mode: 'Service' },
|
||||
{ service: 'unavailable', mode: 'NotRunning', opInFlight: true },
|
||||
{ service: 'unavailable', mode: 'NotRunning', pendingAction: 'reinstall' },
|
||||
] satisfies Partial<RunState>[])(
|
||||
'does not offer Sidecar continuation from %j',
|
||||
(state) => {
|
||||
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||
typeof useQuery
|
||||
>)
|
||||
const dialog = ServiceMigrationDialog()
|
||||
expect(dialog.props.open).toBe(true)
|
||||
expect(dialog.props.disableCancel).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ service: 'notInstalled', mode: 'NotRunning' },
|
||||
{ service: 'versionMismatch', mode: 'NotRunning' },
|
||||
{ service: 'unavailable', mode: 'NotRunning' },
|
||||
{ service: 'notInstalled', mode: 'Sidecar', pendingAction: 'install' },
|
||||
] satisfies Partial<RunState>[])(
|
||||
'offers Sidecar continuation from %j',
|
||||
(state) => {
|
||||
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||
typeof useQuery
|
||||
>)
|
||||
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
|
||||
},
|
||||
)
|
||||
@@ -44,7 +44,24 @@ export const ServiceMigrationDialog = () => {
|
||||
? 'install'
|
||||
: 'reinstall'
|
||||
const open = loading || workflowIncomplete || needsDecision
|
||||
const showCheckingMessage = loading || !needsDecision
|
||||
const checking =
|
||||
loading ||
|
||||
!runState ||
|
||||
runState.opInFlight ||
|
||||
runState.service === 'unknown'
|
||||
const showCheckingMessage = checking || !needsDecision
|
||||
const canContinue = Boolean(
|
||||
runState &&
|
||||
!stateRefreshFailed &&
|
||||
(runState.pendingAction === 'install'
|
||||
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
|
||||
: !runState.pendingAction &&
|
||||
!runState.sidecarAllowed &&
|
||||
runState.mode === 'NotRunning' &&
|
||||
(runState.service === 'notInstalled' ||
|
||||
runState.service === 'versionMismatch' ||
|
||||
runState.service === 'unavailable')),
|
||||
)
|
||||
|
||||
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
|
||||
const refreshRunState = async () => {
|
||||
@@ -165,8 +182,8 @@ export const ServiceMigrationDialog = () => {
|
||||
: 'layout.components.serviceMigration.reinstall',
|
||||
)}
|
||||
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
|
||||
disableOk={loading}
|
||||
disableCancel={loading}
|
||||
disableOk={checking}
|
||||
disableCancel={checking || !canContinue}
|
||||
loading={loading}
|
||||
onOk={() => void handleServiceAction()}
|
||||
onCancel={() => void handleContinue()}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getRuntimeState,
|
||||
installService,
|
||||
patchVergeConfig,
|
||||
reinstallService,
|
||||
restartCore,
|
||||
type FailedOperation,
|
||||
type PendingFailure,
|
||||
@@ -22,22 +23,29 @@ import {
|
||||
} from '@/services/service-request'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
type Remedy = 'installAndRestart' | 'restartOnly'
|
||||
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
|
||||
|
||||
const remedyFor = (reason: ServiceRequestReason): Remedy =>
|
||||
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
|
||||
reason === 'serviceLocationRefused'
|
||||
? 'reinstallAndRestart'
|
||||
: reason === 'sysproxySidecarReady'
|
||||
? 'restartOnly'
|
||||
: 'installAndRestart'
|
||||
|
||||
const EXPLANATION = {
|
||||
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
|
||||
sysproxySidecarReady:
|
||||
'layout.components.sysproxyPrivilege.serviceReadyMessage',
|
||||
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
|
||||
serviceLocationRefused:
|
||||
'layout.components.serviceMigration.locationRefusedMessage',
|
||||
} as const
|
||||
|
||||
const TITLE = {
|
||||
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
|
||||
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
|
||||
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
|
||||
serviceLocationRefused: 'layout.components.serviceMigration.repair',
|
||||
} as const
|
||||
|
||||
const stateToRestore = (
|
||||
@@ -70,7 +78,6 @@ const STEP_MESSAGE = {
|
||||
applying: 'layout.components.sysproxyPrivilege.applying',
|
||||
} as const
|
||||
|
||||
/** Guide recovery from a refused system-proxy write. */
|
||||
export const SysproxyPrivilegeDialog = () => {
|
||||
const { t } = useTranslation()
|
||||
const { failure, dismiss } = useDialogFailure()
|
||||
@@ -92,7 +99,10 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
|
||||
const handleFix = async () => {
|
||||
try {
|
||||
if (remedy === 'installAndRestart') {
|
||||
if (remedy === 'reinstallAndRestart') {
|
||||
setStep('installing')
|
||||
await reinstallService()
|
||||
} else if (remedy === 'installAndRestart') {
|
||||
setStep('installing')
|
||||
await installService()
|
||||
}
|
||||
@@ -101,6 +111,7 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
|
||||
const runState = await getRuntimeState()
|
||||
const usingAdminFallback =
|
||||
remedy !== 'reinstallAndRestart' &&
|
||||
getSystem() === 'windows' &&
|
||||
runState.mode === 'Sidecar' &&
|
||||
runState.isAdmin &&
|
||||
@@ -113,9 +124,11 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
}
|
||||
if (!usingAdminFallback) {
|
||||
showNotice.success(
|
||||
restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
remedy === 'reinstallAndRestart'
|
||||
? 'layout.components.serviceMigration.success'
|
||||
: restoring === undefined
|
||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||
)
|
||||
}
|
||||
close()
|
||||
@@ -136,9 +149,11 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
open={Boolean(request)}
|
||||
title={t(TITLE[reason])}
|
||||
okBtn={t(
|
||||
remedy === 'installAndRestart'
|
||||
? 'settings.sections.proxyControl.actions.installService'
|
||||
: 'settings.sections.proxyControl.actions.switchToServiceMode',
|
||||
remedy === 'reinstallAndRestart'
|
||||
? 'layout.components.serviceMigration.reinstall'
|
||||
: remedy === 'installAndRestart'
|
||||
? 'settings.sections.proxyControl.actions.installService'
|
||||
: 'settings.sections.proxyControl.actions.switchToServiceMode',
|
||||
)}
|
||||
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
|
||||
// Keep the primary spinner visible; only cancellation is unavailable.
|
||||
|
||||
@@ -33,6 +33,7 @@ type EditorLanguage = 'yaml' | 'javascript' | 'css'
|
||||
export interface EditorViewerProps {
|
||||
open: boolean
|
||||
title?: string | ReactNode
|
||||
description?: ReactNode
|
||||
value: string
|
||||
language: EditorLanguage
|
||||
path: string
|
||||
@@ -50,6 +51,7 @@ export interface EditorViewerProps {
|
||||
export const EditorViewer = ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
language,
|
||||
path,
|
||||
@@ -207,6 +209,7 @@ export const EditorViewer = ({
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
|
||||
<BaseLoadingOverlay isLoading={loading} />
|
||||
{!loading && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Alert, Box, Typography } from '@mui/material'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
const stageHints = {
|
||||
globalMerge: 'profiles.modals.editor.enhance.globalMerge',
|
||||
globalScript: 'profiles.modals.editor.enhance.globalScript',
|
||||
profileMerge: 'profiles.modals.editor.enhance.profileMerge',
|
||||
profileScript: 'profiles.modals.editor.enhance.profileScript',
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
stage: keyof typeof stageHints
|
||||
}
|
||||
|
||||
export const EnhanceHint = ({ stage }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const orderComponents = Object.fromEntries(
|
||||
Object.keys(stageHints).map((key) => [
|
||||
key,
|
||||
<Box
|
||||
key={key}
|
||||
component={key === stage ? 'strong' : 'span'}
|
||||
sx={
|
||||
key === stage
|
||||
? {
|
||||
fontWeight: 'bold',
|
||||
color: 'primary.contrastText',
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: 0.5,
|
||||
px: 0.5,
|
||||
boxDecorationBreak: 'clone',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
]),
|
||||
)
|
||||
|
||||
return (
|
||||
<Alert severity="info" sx={{ mb: 1.5, flexShrink: 0 }}>
|
||||
<Typography variant="body2">{t(stageHints[stage])}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
<Trans
|
||||
defaults={t('profiles.modals.editor.enhance.order')}
|
||||
components={orderComponents}
|
||||
/>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||
{t('profiles.modals.editor.enhance.settingsPriority')}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { isValidUrl } from '@/utils/network'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
import { EnhanceHint } from './enhance-hint'
|
||||
import { ProfileBox } from './profile-box'
|
||||
import { ProxiesEditorViewer } from './proxies-editor-viewer'
|
||||
import { QrViewer } from './qr-viewer'
|
||||
@@ -910,6 +911,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
{mergeOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={t('profiles.components.menu.extendConfig')}
|
||||
description={<EnhanceHint stage="profileMerge" />}
|
||||
value={mergeDocument.value}
|
||||
language="yaml"
|
||||
path={`merge:${option?.merge ?? ''}.yaml`}
|
||||
@@ -923,6 +926,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
{scriptOpen && (
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={t('profiles.components.menu.extendScript')}
|
||||
description={<EnhanceHint stage="profileScript" />}
|
||||
value={scriptDocument.value}
|
||||
language="javascript"
|
||||
path={`script:${option?.script ?? ''}.js`}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useEditorDocument } from '@/hooks/use-editor-document'
|
||||
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
import { EnhanceHint } from './enhance-hint'
|
||||
import { LogViewer } from './log-viewer'
|
||||
import { ProfileBox } from './profile-box'
|
||||
|
||||
@@ -213,6 +214,11 @@ export const ProfileMore = (props: Props) => {
|
||||
<EditorViewer
|
||||
open={true}
|
||||
title={t(globalTitles[id])}
|
||||
description={
|
||||
<EnhanceHint
|
||||
stage={id === 'Merge' ? 'globalMerge' : 'globalScript'}
|
||||
/>
|
||||
}
|
||||
value={document.value}
|
||||
language={id === 'Merge' ? 'yaml' : 'javascript'}
|
||||
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RestartAltRounded } from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
Switch,
|
||||
} from '@/components/base'
|
||||
import { useClash } from '@/hooks/use-clash'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
@@ -186,6 +188,12 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation()
|
||||
const { mutateClash } = useClash()
|
||||
const { verge } = useVerge()
|
||||
const { current: currentProfile } = useProfiles()
|
||||
const dnsEnabled = currentProfile
|
||||
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
|
||||
verge?.enable_dns_settings ??
|
||||
false)
|
||||
: false
|
||||
const themeMode = useThemeMode()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -346,6 +354,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
'fake-ip-filter': parseList(values.fakeIpFilter),
|
||||
'default-nameserver': parseList(values.defaultNameserver),
|
||||
nameserver: parseList(values.nameserver),
|
||||
'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy),
|
||||
'direct-nameserver-follow-policy': values.directNameserverFollowPolicy,
|
||||
'fallback-filter': {
|
||||
geoip: values.fallbackGeoip,
|
||||
@@ -359,11 +368,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
'direct-nameserver': parseList(values.directNameserver),
|
||||
}
|
||||
|
||||
const policy = parseNameserverPolicy(values.nameserverPolicy)
|
||||
if (Object.keys(policy).length > 0) {
|
||||
dnsConfig['nameserver-policy'] = policy
|
||||
}
|
||||
|
||||
return dnsConfig
|
||||
}, [values])
|
||||
|
||||
@@ -375,10 +379,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
config.dns = dnsConfig
|
||||
}
|
||||
|
||||
const hosts = parseHosts(values.hosts)
|
||||
if (Object.keys(hosts).length > 0) {
|
||||
config.hosts = hosts
|
||||
}
|
||||
config.hosts = parseHosts(values.hosts)
|
||||
|
||||
setYamlContent(yaml.dump(config, { forceQuotes: true }))
|
||||
}, [generateDnsConfig, values.hosts])
|
||||
@@ -507,10 +508,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
config.dns = dnsConfig
|
||||
}
|
||||
|
||||
const hosts = parseHosts(values.hosts)
|
||||
if (Object.keys(hosts).length > 0) {
|
||||
config.hosts = hosts
|
||||
}
|
||||
config.hosts = parseHosts(values.hosts)
|
||||
} else {
|
||||
const parsedConfig = yaml.load(yamlContent)
|
||||
if (typeof parsedConfig !== 'object' || parsedConfig === null) {
|
||||
@@ -560,7 +558,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (verge?.enable_dns_settings) {
|
||||
if (dnsEnabled) {
|
||||
await invoke('apply_dns_config', { apply: true })
|
||||
mutateClash()
|
||||
}
|
||||
@@ -648,6 +646,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={onSave}
|
||||
>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
{t('settings.modals.dns.dialog.profileScope')}
|
||||
</Alert>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="warning.main"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
|
||||
import { useClash } from '@/hooks/use-clash'
|
||||
import { useClashLog } from '@/hooks/use-clash-log'
|
||||
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { invoke_uwp_tool, setDnsOverride } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -36,6 +37,12 @@ const SettingClash = ({ onError }: Props) => {
|
||||
|
||||
const { clash, version, mutateClash, patchClash } = useClash()
|
||||
const { verge, mutateVerge } = useVerge()
|
||||
const { current: currentProfile } = useProfiles()
|
||||
const dnsEnabled = currentProfile
|
||||
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
|
||||
verge?.enable_dns_settings ??
|
||||
false)
|
||||
: false
|
||||
const displayedMixedPort = useDisplayedMixedPort()
|
||||
const [, setClashLog] = useClashLog()
|
||||
|
||||
@@ -46,7 +53,10 @@ const SettingClash = ({ onError }: Props) => {
|
||||
'unified-delay': unifiedDelay,
|
||||
} = clash ?? {}
|
||||
|
||||
const [dnsConfirmation, setDnsConfirmation] = useState<string | null>(null)
|
||||
const [dnsConfirmation, setDnsConfirmation] = useState<{
|
||||
profileUid: string
|
||||
source: string
|
||||
} | null>(null)
|
||||
const [dnsUpdating, setDnsUpdating] = useState(false)
|
||||
|
||||
const webRef = useRef<DialogRef>(null)
|
||||
@@ -72,12 +82,17 @@ const SettingClash = ({ onError }: Props) => {
|
||||
}
|
||||
|
||||
const handleDnsToggle = useLockFn(
|
||||
async (enable: boolean, confirmation?: string) => {
|
||||
async (
|
||||
enable: boolean,
|
||||
confirmation?: string,
|
||||
profileUid = currentProfile?.uid,
|
||||
) => {
|
||||
if (!profileUid) return
|
||||
setDnsUpdating(true)
|
||||
try {
|
||||
const outcome = await setDnsOverride(enable, confirmation)
|
||||
const outcome = await setDnsOverride(profileUid, enable, confirmation)
|
||||
if (outcome.status === 'confirmation_required') {
|
||||
setDnsConfirmation(outcome.source)
|
||||
setDnsConfirmation({ profileUid, source: outcome.source })
|
||||
} else {
|
||||
setDnsConfirmation(null)
|
||||
}
|
||||
@@ -114,7 +129,12 @@ const SettingClash = ({ onError }: Props) => {
|
||||
onCancel={closeDnsConfirmation}
|
||||
onClose={closeDnsConfirmation}
|
||||
onOk={() => {
|
||||
if (dnsConfirmation) void handleDnsToggle(true, dnsConfirmation)
|
||||
if (dnsConfirmation)
|
||||
void handleDnsToggle(
|
||||
true,
|
||||
dnsConfirmation.source,
|
||||
dnsConfirmation.profileUid,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
@@ -151,16 +171,22 @@ const SettingClash = ({ onError }: Props) => {
|
||||
<SettingItem
|
||||
label={t('settings.sections.clash.form.fields.dnsOverwrite')}
|
||||
extra={
|
||||
<TooltipIcon
|
||||
icon={SettingsRounded}
|
||||
onClick={() => dnsRef.current?.open()}
|
||||
/>
|
||||
<>
|
||||
<TooltipIcon
|
||||
icon={SettingsRounded}
|
||||
onClick={() => dnsRef.current?.open()}
|
||||
/>
|
||||
<TooltipIcon
|
||||
title={t('settings.modals.dns.dialog.profileScope')}
|
||||
sx={{ opacity: '0.7' }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={verge?.enable_dns_settings ?? false}
|
||||
disabled={dnsUpdating || dnsConfirmation !== null}
|
||||
checked={dnsEnabled}
|
||||
disabled={!currentProfile || dnsUpdating || dnsConfirmation !== null}
|
||||
onChange={(_, checked) => handleDnsToggle(checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "النواة لا تعمل",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "وضع الخدمة",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "لا يمكن للخدمة تشغيل النواة لأن مسار التثبيت أو أذونات المجلد لم تجتز فحص الأمان. أعد تثبيت الخدمة لاستعادة ملفاتها وأذوناتها، ثم حاول مجددًا. قد يطلب Windows موافقة المسؤول.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "كتبت الإضافات الحقول {{keys}} التي تديرها الإعدادات، لذلك تم تجاهل هذه القيم",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "لا يمكن التعديل في محرر القراءة فقط"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "يُطبّق على جميع الملفات بعد إعدادات التطبيق. يمكن للسكربت العام وإعدادات الملف وسكربت الملف تعديل النتيجة لاحقًا.",
|
||||
"globalScript": "يُطبّق على جميع الملفات باستخدام نتيجة دمج الإعدادات العامة. يمكن لإعدادات الملف وسكربت الملف تعديل النتيجة لاحقًا.",
|
||||
"profileMerge": "يُطبّق على هذا الملف فقط بعد الإعدادات العامة والسكربت العام. يمكن لسكربت الملف تعديل النتيجة لاحقًا.",
|
||||
"profileScript": "يُطبّق على هذا الملف فقط باستخدام نتيجة دمج إعداداته. هذه آخر خطوة للإضافات قبل إعادة تطبيق إعدادات التطبيق.",
|
||||
"order": "الترتيب: الملف الأصلي ← إعدادات التطبيق ← <globalMerge>إعدادات الإضافة العامة</globalMerge> ← <globalScript>سكربت الإضافة العام</globalScript> ← <profileMerge>إعدادات إضافة الملف</profileMerge> ← <profileScript>سكربت إضافة الملف</profileScript> ← إعادة تطبيق إعدادات التطبيق",
|
||||
"settingsPriority": "ملاحظة: الحقول التي تديرها إعدادات التطبيق تستخدم دائمًا قيم تلك الإعدادات في التكوين النهائي. تعديل هذه الحقول في الإضافات لا يؤثر على النتيجة النهائية."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS Overwrite",
|
||||
"profileScope": "تُحفظ حالة مفتاح تجاوز DNS بشكل مستقل لكل ملف تعريف، وتُطبَّق على ذلك الملف فقط.",
|
||||
"warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled"
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.",
|
||||
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Kern läuft nicht",
|
||||
"adminMode": "Administrator-Modus",
|
||||
"serviceMode": "Service-Modus",
|
||||
"sidecarMode": "Benutzermodus",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "Der Dienst kann den Kern nicht starten, da sein Installationspfad oder die Verzeichnisberechtigungen die Sicherheitsprüfung nicht bestanden haben. Installieren Sie den Dienst erneut, um Dateien und Berechtigungen wiederherzustellen, und versuchen Sie es erneut. Windows kann eine Administratorbestätigung anfordern.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "Erweiterungen haben {{keys}} gesetzt, die von den Einstellungen verwaltet werden, daher wurden diese Werte verworfen",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "Bearbeitung im schreibgeschützten Modus nicht möglich"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "Gilt für alle Profile, nach den App-Einstellungen. Das globale Skript, die Profilkonfiguration und das Profilskript können das Ergebnis noch ändern.",
|
||||
"globalScript": "Gilt für alle Profile und verwendet das Ergebnis der globalen Konfiguration. Die Profilkonfiguration und das Profilskript können das Ergebnis noch ändern.",
|
||||
"profileMerge": "Gilt nur für dieses Profil, nach der globalen Konfiguration und dem globalen Skript. Das Profilskript kann das Ergebnis noch ändern.",
|
||||
"profileScript": "Gilt nur für dieses Profil und verwendet das Ergebnis der Profilkonfiguration. Dies ist der letzte Erweiterungsschritt vor dem erneuten Anwenden der App-Einstellungen.",
|
||||
"order": "Reihenfolge: Originalprofil → App-Einstellungen → <globalMerge>Globale Erweiterungskonfiguration</globalMerge> → <globalScript>Globales Erweiterungsskript</globalScript> → <profileMerge>Profilerweiterungskonfiguration</profileMerge> → <profileScript>Profilerweiterungsskript</profileScript> → App-Einstellungen erneut anwenden",
|
||||
"settingsPriority": "Hinweis: Von den App-Einstellungen verwaltete Felder übernehmen in der endgültigen Konfiguration immer deren Werte. Änderungen dieser Felder in Erweiterungen haben keine Wirkung."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS-Überschreibung",
|
||||
"profileScope": "Der DNS-Überschreibungsschalter wird für jedes Profil separat gespeichert und gilt nur für dieses Profil.",
|
||||
"warning": "Wenn Sie sich nicht mit diesen Einstellungen auskennen, ändern Sie sie nicht und lassen Sie die DNS-Überschreibung deaktiviert."
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service erfolgreich installiert",
|
||||
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
|
||||
"sidecarFallback": "Der Dienst konnte den Kern nicht starten. Die Anwendung läuft im Sidecar-Modus weiter; der Dienst bleibt nicht verfügbar.",
|
||||
"uninstallSuccess": "Service erfolgreich deinstalliert"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Settings"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Core Not Running",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "Service Mode",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "The service cannot start the core because its installation path or directory permissions failed a security check. Reinstall the service to restore its files and permissions, then try again. Windows may ask for administrator approval.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "Extensions wrote {{keys}}, which Settings manages, so those values were discarded",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "Cannot edit in read-only editor"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "Applies to all profiles, after app settings. The global script, profile config and profile script can still change the result.",
|
||||
"globalScript": "Applies to all profiles, using the result of the global config merge. The profile config and profile script can still change the result.",
|
||||
"profileMerge": "Applies only to this profile, after the global config and global script. The profile script can still change the result.",
|
||||
"profileScript": "Applies only to this profile, using the result of the profile config merge. This is the last extension step before app settings are reapplied.",
|
||||
"order": "Order: Original profile → App settings → <globalMerge>Global extend config</globalMerge> → <globalScript>Global extend script</globalScript> → <profileMerge>Profile extend config</profileMerge> → <profileScript>Profile extend script</profileScript> → App settings reapplied",
|
||||
"settingsPriority": "Note: Fields managed by app settings always use those settings in the final config. Changing these fields in extensions has no effect."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS Overwrite",
|
||||
"profileScope": "The DNS override switch is saved separately for each profile and applies only to that profile.",
|
||||
"warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled"
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Service Installed Successfully",
|
||||
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
|
||||
"sidecarFallback": "The service could not start the core. Switched to Sidecar mode; the service remains unavailable.",
|
||||
"uninstallSuccess": "Service Uninstalled Successfully"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Ajustes"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Núcleo detenido",
|
||||
"adminMode": "Modo de administrador",
|
||||
"serviceMode": "Modo de servicio",
|
||||
"sidecarMode": "Modo de usuario",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "El servicio no puede iniciar el núcleo porque su ruta de instalación o los permisos del directorio no superaron una comprobación de seguridad. Reinstale el servicio para restaurar sus archivos y permisos e inténtelo de nuevo. Windows puede solicitar autorización de administrador.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "Las extensiones escribieron {{keys}}, campos gestionados por los ajustes, por lo que esos valores se descartaron",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "No se puede editar en modo de solo lectura"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "Se aplica a todos los perfiles, después de los ajustes de la aplicación. El script global, la configuración del perfil y el script del perfil aún pueden modificar el resultado.",
|
||||
"globalScript": "Se aplica a todos los perfiles sobre el resultado de combinar la configuración global. La configuración y el script del perfil aún pueden modificar el resultado.",
|
||||
"profileMerge": "Se aplica solo a este perfil, después de la configuración y el script globales. El script del perfil aún puede modificar el resultado.",
|
||||
"profileScript": "Se aplica solo a este perfil sobre el resultado de combinar su configuración. Es el último paso de extensión antes de volver a aplicar los ajustes de la aplicación.",
|
||||
"order": "Orden: Perfil original → Ajustes de la aplicación → <globalMerge>Configuración de extensión global</globalMerge> → <globalScript>Script de extensión global</globalScript> → <profileMerge>Configuración de extensión del perfil</profileMerge> → <profileScript>Script de extensión del perfil</profileScript> → Reaplicar ajustes de la aplicación",
|
||||
"settingsPriority": "Nota: Los campos gestionados por los ajustes de la aplicación siempre conservan esos valores en la configuración final. Modificarlos en las extensiones no tiene efecto."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "Sobrescritura de DNS",
|
||||
"profileScope": "El estado del interruptor de sobrescritura de DNS se guarda por separado para cada perfil y solo se aplica a ese perfil.",
|
||||
"warning": "Si no está seguro de cómo configurar esto, no realice cambios y mantenga deshabilitada la sobrescritura de DNS."
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Servicio instalado con éxito",
|
||||
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
|
||||
"sidecarFallback": "El servicio no pudo iniciar el núcleo. Se ha cambiado al modo Sidecar; el servicio sigue sin estar disponible.",
|
||||
"uninstallSuccess": "Servicio desinstalado con éxito"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "تنظیمات"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "هسته در حال اجرا نیست",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "حالت سرویس",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "سرویس نمیتواند هسته را اجرا کند، زیرا مسیر نصب یا مجوزهای پوشه در بررسی امنیتی تأیید نشدهاند. برای بازیابی فایلها و مجوزها، سرویس را دوباره نصب و سپس مجدداً تلاش کنید. ممکن است ویندوز تأیید مدیر را درخواست کند.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "افزونهها فیلدهای {{keys}} را نوشتند که توسط تنظیمات مدیریت میشوند، بنابراین این مقادیر نادیده گرفته شدند",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "نمیتوان در ویرایشگر فقط خواندنی ویرایش کرد"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "پس از تنظیمات برنامه روی همهٔ پروفایلها اعمال میشود. اسکریپت سراسری، پیکربندی پروفایل و اسکریپت پروفایل همچنان میتوانند نتیجه را تغییر دهند.",
|
||||
"globalScript": "با استفاده از نتیجهٔ ادغام پیکربندی سراسری روی همهٔ پروفایلها اعمال میشود. پیکربندی و اسکریپت پروفایل همچنان میتوانند نتیجه را تغییر دهند.",
|
||||
"profileMerge": "پس از پیکربندی و اسکریپت سراسری فقط روی این پروفایل اعمال میشود. اسکریپت پروفایل همچنان میتواند نتیجه را تغییر دهد.",
|
||||
"profileScript": "با استفاده از نتیجهٔ ادغام پیکربندی پروفایل فقط روی این پروفایل اعمال میشود. این آخرین مرحلهٔ افزونه پیش از اعمال مجدد تنظیمات برنامه است.",
|
||||
"order": "ترتیب: پروفایل اصلی ← تنظیمات برنامه ← <globalMerge>پیکربندی افزونهٔ سراسری</globalMerge> ← <globalScript>اسکریپت افزونهٔ سراسری</globalScript> ← <profileMerge>پیکربندی افزونهٔ پروفایل</profileMerge> ← <profileScript>اسکریپت افزونهٔ پروفایل</profileScript> ← اعمال مجدد تنظیمات برنامه",
|
||||
"settingsPriority": "توجه: فیلدهایی که تنظیمات برنامه مدیریت میکند، در پیکربندی نهایی همیشه از مقادیر همان تنظیمات استفاده میکنند. تغییر این فیلدها در افزونهها اثری بر پیکربندی نهایی ندارد."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "بازنویسی DNS",
|
||||
"profileScope": "وضعیت کلید بازنویسی DNS برای هر پروفایل جداگانه ذخیره میشود و فقط بر همان پروفایل اعمال میشود.",
|
||||
"warning": "اگر با این تنظیمات آشنا نیستید، لطفاً آنها را تغییر ندهید و DNS Overwrite را غیرفعال نگه دارید."
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "سرویس با موفقیت نصب شد",
|
||||
"sidecarFallback": "سرویس بهدلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
|
||||
"sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه مییابد؛ سرویس همچنان در دسترس نیست.",
|
||||
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Pengaturan"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Inti tidak berjalan",
|
||||
"adminMode": "Administrator Mode",
|
||||
"serviceMode": "Mode Layanan",
|
||||
"sidecarMode": "User Mode",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "Layanan tidak dapat memulai inti karena jalur instalasi atau izin direktori tidak lolos pemeriksaan keamanan. Pasang ulang layanan untuk memulihkan berkas dan izinnya, lalu coba lagi. Windows mungkin meminta persetujuan administrator.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "Ekstensi menulis {{keys}} yang dikelola oleh Pengaturan, sehingga nilai tersebut diabaikan",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "Tidak dapat mengedit di editor hanya baca"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "Berlaku untuk semua profil, setelah pengaturan aplikasi. Skrip global, konfigurasi profil, dan skrip profil masih dapat mengubah hasilnya.",
|
||||
"globalScript": "Berlaku untuk semua profil, menggunakan hasil penggabungan konfigurasi global. Konfigurasi profil dan skrip profil masih dapat mengubah hasilnya.",
|
||||
"profileMerge": "Berlaku hanya untuk profil ini, setelah konfigurasi global dan skrip global. Skrip profil masih dapat mengubah hasilnya.",
|
||||
"profileScript": "Berlaku hanya untuk profil ini, menggunakan hasil penggabungan konfigurasi profil. Ini adalah langkah ekstensi terakhir sebelum pengaturan aplikasi diterapkan kembali.",
|
||||
"order": "Urutan: Profil asli → Pengaturan aplikasi → <globalMerge>Konfigurasi ekstensi global</globalMerge> → <globalScript>Skrip ekstensi global</globalScript> → <profileMerge>Konfigurasi ekstensi profil</profileMerge> → <profileScript>Skrip ekstensi profil</profileScript> → Penerapan ulang pengaturan aplikasi",
|
||||
"settingsPriority": "Catatan: Bidang yang dikelola oleh pengaturan aplikasi selalu menggunakan nilai pengaturan tersebut dalam konfigurasi akhir. Mengubah bidang ini melalui ekstensi tidak berpengaruh."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS Overwrite",
|
||||
"profileScope": "Status sakelar penimpaan DNS disimpan secara terpisah untuk setiap profil dan hanya berlaku untuk profil tersebut.",
|
||||
"warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled"
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "Layanan Berhasil Diinstal",
|
||||
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
|
||||
"sidecarFallback": "Layanan tidak dapat memulai inti. Beralih ke mode Sidecar; layanan masih tidak tersedia.",
|
||||
"uninstallSuccess": "Layanan Berhasil Dicopot"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "設定"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "コア停止中",
|
||||
"adminMode": "管理者モード",
|
||||
"serviceMode": "サービスモード",
|
||||
"sidecarMode": "ユーザーモード",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "サービスのインストール先またはフォルダーのアクセス許可がセキュリティチェックに合格しなかったため、コアを起動できません。サービスを再インストールしてファイルとアクセス許可を復元し、再試行してください。Windows が管理者の承認を求める場合があります。",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Force refresh completed",
|
||||
"discardedKeys": "拡張設定またはスクリプトが、アプリ設定で管理される {{keys}} を書き込んだため、その値は破棄されました",
|
||||
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "読み取り専用モードでは編集できません。"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "すべてのプロファイルに適用され、アプリ設定の後にマージされます。後続のグローバルスクリプト、プロファイル拡張設定・スクリプトで結果を変更できます。",
|
||||
"globalScript": "すべてのプロファイルに適用され、グローバル拡張設定のマージ結果を受け取ります。後続のプロファイル拡張設定・スクリプトで結果を変更できます。",
|
||||
"profileMerge": "このプロファイルのみに適用され、グローバル拡張設定・スクリプトの後にマージされます。後続のプロファイルスクリプトで結果を変更できます。",
|
||||
"profileScript": "このプロファイルのみに適用され、プロファイル拡張設定のマージ結果を受け取ります。アプリ設定が再適用される前の最後の拡張ステップです。",
|
||||
"order": "実行順序:元のプロファイル → アプリ設定 → <globalMerge>グローバル拡張設定</globalMerge> → <globalScript>グローバル拡張スクリプト</globalScript> → <profileMerge>プロファイル拡張設定</profileMerge> → <profileScript>プロファイル拡張スクリプト</profileScript> → アプリ設定の再適用",
|
||||
"settingsPriority": "注意:アプリ設定が管理するフィールドは、最終的にアプリ設定の値が優先されます。拡張設定やスクリプトでこれらのフィールドを変更しても反映されません。"
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS上書き",
|
||||
"profileScope": "DNS 上書きスイッチの状態はプロファイルごとに保存され、そのプロファイルにのみ適用されます。",
|
||||
"warning": "ここの設定がわからない場合は、変更しないでください。DNS上書きを無効にしたままにしてください。"
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "サービスのインストールに成功しました。",
|
||||
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
|
||||
"sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。",
|
||||
"uninstallSuccess": "サービスのアンインストールに成功しました。"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "설정"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "코어 실행 중이 아님",
|
||||
"adminMode": "관리자 모드",
|
||||
"serviceMode": "서비스 모드",
|
||||
"sidecarMode": "사용자 모드",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "서비스 설치 경로나 폴더 권한이 보안 검사를 통과하지 못해 코어를 시작할 수 없습니다. 서비스를 다시 설치하여 파일과 권한을 복원한 후 다시 시도하세요. Windows에서 관리자 승인을 요청할 수 있습니다.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "강제 새로고침 완료",
|
||||
"discardedKeys": "확장 설정 또는 스크립트가 앱 설정이 관리하는 {{keys}} 필드를 작성하여 해당 값은 무시되었습니다",
|
||||
"emergencyRefreshFailed": "긴급 새로고침 실패: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "읽기 전용 편집기에서는 편집할 수 없습니다"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "모든 프로필에 적용되며 앱 설정 이후에 병합됩니다. 이후의 전역 스크립트, 프로필 확장 설정 및 스크립트가 결과를 변경할 수 있습니다.",
|
||||
"globalScript": "모든 프로필에 적용되며 전역 확장 설정이 병합된 결과를 받습니다. 이후의 프로필 확장 설정 및 스크립트가 결과를 변경할 수 있습니다.",
|
||||
"profileMerge": "이 프로필에만 적용되며 전역 확장 설정 및 스크립트 이후에 병합됩니다. 이후의 프로필 스크립트가 결과를 변경할 수 있습니다.",
|
||||
"profileScript": "이 프로필에만 적용되며 프로필 확장 설정이 병합된 결과를 받습니다. 앱 설정이 다시 적용되기 전의 마지막 확장 단계입니다.",
|
||||
"order": "실행 순서: 원본 프로필 → 앱 설정 → <globalMerge>전역 확장 설정</globalMerge> → <globalScript>전역 확장 스크립트</globalScript> → <profileMerge>프로필 확장 설정</profileMerge> → <profileScript>프로필 확장 스크립트</profileScript> → 앱 설정 재적용",
|
||||
"settingsPriority": "참고: 앱 설정이 관리하는 필드는 최종 구성에서 항상 앱 설정의 값을 사용합니다. 확장 설정이나 스크립트에서 이 필드를 변경해도 반영되지 않습니다."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
@@ -508,6 +508,7 @@
|
||||
"dns": {
|
||||
"dialog": {
|
||||
"title": "DNS 덮어쓰기",
|
||||
"profileScope": "DNS 덮어쓰기 스위치 상태는 프로필별로 저장되며 해당 프로필에만 적용됩니다.",
|
||||
"warning": "이 설정에 익숙하지 않다면 수정하지 말고 DNS 덮어쓰기를 비활성화 상태로 유지하세요"
|
||||
},
|
||||
"protection": {
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"clashService": {
|
||||
"installSuccess": "서비스가 설치되었습니다",
|
||||
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
|
||||
"sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.",
|
||||
"uninstallSuccess": "서비스가 제거되었습니다"
|
||||
},
|
||||
"updater": {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"badges": {
|
||||
"notRunning": "Ядро не запущено",
|
||||
"adminMode": "Режим администратора",
|
||||
"serviceMode": "Режим службы",
|
||||
"sidecarMode": "Пользовательский режим",
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
}
|
||||
},
|
||||
"serviceMigration": {
|
||||
"locationRefusedMessage": "Служба не может запустить ядро: путь установки или права доступа к каталогу не прошли проверку безопасности. Переустановите службу, чтобы восстановить файлы и права доступа, затем повторите попытку. Windows может запросить подтверждение администратора.",
|
||||
"title": "Service update required",
|
||||
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
|
||||
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
|
||||
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
|
||||
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
|
||||
"reinstall": "Reinstall service",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"notices": {
|
||||
"forceRefreshCompleted": "Принудительное обновление завершено",
|
||||
"discardedKeys": "Расширения записали поля {{keys}}, которыми управляют настройки приложения, поэтому эти значения отброшены",
|
||||
"emergencyRefreshFailed": "Экстренное обновление не удалось: {{message}}"
|
||||
},
|
||||
"tooltips": {
|
||||
@@ -196,6 +197,14 @@
|
||||
},
|
||||
"messages": {
|
||||
"readOnly": "Редактирование недоступно в режиме только для чтения"
|
||||
},
|
||||
"enhance": {
|
||||
"globalMerge": "Применяется ко всем профилям после настроек приложения. Глобальный скрипт, конфигурация и скрипт профиля ещё могут изменить результат.",
|
||||
"globalScript": "Применяется ко всем профилям после объединения с глобальной конфигурацией. Конфигурация и скрипт профиля ещё могут изменить результат.",
|
||||
"profileMerge": "Применяется только к этому профилю после глобальной конфигурации и глобального скрипта. Скрипт профиля ещё может изменить результат.",
|
||||
"profileScript": "Применяется только к этому профилю после объединения с его конфигурацией. Это последний шаг расширения перед повторным применением настроек приложения.",
|
||||
"order": "Порядок: Исходный профиль → Настройки приложения → <globalMerge>Глобальная конфигурация расширения</globalMerge> → <globalScript>Глобальный скрипт расширения</globalScript> → <profileMerge>Конфигурация расширения профиля</profileMerge> → <profileScript>Скрипт расширения профиля</profileScript> → Повторное применение настроек приложения",
|
||||
"settingsPriority": "Примечание: Поля, управляемые настройками приложения, в итоговой конфигурации всегда используют значения этих настроек. Изменения таких полей в расширениях не действуют."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user