mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 13:50:47 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea509b8236 | ||
|
|
52d616302c | ||
|
|
8ddd99f226 | ||
|
|
e0e97a920e | ||
|
|
c4f9d65e07 | ||
|
|
0c7e0c8535 | ||
|
|
1638ab48d8 | ||
|
|
5d7dc3aed9 | ||
|
|
b057bd964c | ||
|
|
897a117dc5 | ||
|
|
6a752994cf | ||
|
|
32e5d5bda3 | ||
|
|
daae17a40c | ||
|
|
1f140dfd3a | ||
|
|
04d74ac48f | ||
|
|
78bf79cc09 | ||
|
|
61c33c6113 | ||
|
|
ae4f416766 | ||
|
|
fbc4b516b8 | ||
|
|
3456de1fb6 | ||
|
|
f9ef9d78cb | ||
|
|
3458c9b075 | ||
|
|
afc32f568a | ||
|
|
22e3f1ac8a | ||
|
|
758300ac34 | ||
|
|
659ee1a181 | ||
|
|
fd06fbb3b7 | ||
|
|
ed0b801573 | ||
|
|
ff1ce92fac | ||
|
|
6a85d0344e | ||
|
|
bffe91db5e | ||
|
|
93b7bb8fe8 | ||
|
|
3b958acb22 | ||
|
|
d374ab4350 | ||
|
|
b7197f6f4b | ||
|
|
7bf8e1dcf5 | ||
|
|
07825afb1e | ||
|
|
d7e0126586 | ||
|
|
5059cdcb9d | ||
|
|
af0f502f98 | ||
|
|
8982525fd2 | ||
|
|
81ce247ecc | ||
|
|
b469bee352 | ||
|
|
df79ed3efb | ||
|
|
c6a4de1c6a | ||
|
|
433cd0628f |
@@ -5,7 +5,7 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.5.1"
|
||||
"pnpmVersion": "12.8.1"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.98.1",
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.89.15": {
|
||||
"github/gh-aw-actions/setup@v0.89.21": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.89.15",
|
||||
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
|
||||
"version": "v0.89.21",
|
||||
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
@@ -27,6 +27,11 @@
|
||||
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
|
||||
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||
@@ -42,6 +47,11 @@
|
||||
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
|
||||
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||
@@ -57,20 +67,25 @@
|
||||
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
|
||||
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
|
||||
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
|
||||
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-node": {
|
||||
"image": "ghcr.io/github/gh-aw-node",
|
||||
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
||||
},
|
||||
"ghcr.io/github/github-mcp-server:v1.12.1": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
|
||||
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
|
||||
"ghcr.io/github/github-mcp-server:v1.12.2": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
|
||||
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -580,7 +580,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
|
||||
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
|
||||
with:
|
||||
version: v0.89.15
|
||||
|
||||
+99
-58
File diff suppressed because one or more lines are too long
@@ -144,7 +144,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -691,7 +691,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.5.1
|
||||
pnpm 12.8.1
|
||||
rust 1.98.1
|
||||
|
||||
Generated
+11
-11
@@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clash-verge"
|
||||
version = "2.5.4"
|
||||
version = "2.5.7"
|
||||
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.6"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"kode-bridge",
|
||||
@@ -6922,7 +6922,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sysproxy"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/clash-verge-rev/sysproxy-rs.git?branch=main#44aaf00ec9c6779e5a461a55d882eddff7841c98"
|
||||
source = "git+https://github.com/clash-verge-rev/sysproxy-rs.git?branch=main#89ad876a0942c049ae72e1472f9fc3ed62023c5e"
|
||||
dependencies = [
|
||||
"iptools",
|
||||
"log",
|
||||
@@ -7071,9 +7071,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.5"
|
||||
version = "2.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
|
||||
checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -7350,9 +7350,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
|
||||
checksum = "d4c07d3be2c68e22e64012eb6c4ec969247e3800da26ef3f3856afbcd07a1ce6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
@@ -7375,7 +7375,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tauri-plugin-mihomo"
|
||||
version = "0.6.0"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#17c7757389ca73d29882753ecf7a160de29aabbe"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#ba8434c08c869916b8041d707ad66599bb5230b2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"clashmap",
|
||||
@@ -7471,9 +7471,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
|
||||
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs 6.0.0",
|
||||
|
||||
+2
-1
@@ -63,7 +63,7 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
tracing = "0.1.44"
|
||||
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri = { version = "2.11.6" }
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
@@ -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",
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
+11
-82
@@ -1,106 +1,35 @@
|
||||
## v2.5.4
|
||||
## v2.5.7
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
||||
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
||||
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
||||
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
||||
|
||||
**🍎/🐧 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 系统主题跟随,自动切换深浅色外观
|
||||
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
||||
|
||||
</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,180 @@
|
||||
## v2.5.6
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
||||
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
||||
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
|
||||
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
|
||||
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
|
||||
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
|
||||
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
|
||||
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
|
||||
|
||||
</details>
|
||||
|
||||
## v2.5.5
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
|
||||
</details>
|
||||
|
||||
## v2.5.4
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「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 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.2
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
+3
-3
@@ -51,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
|
||||
## Promotion
|
||||
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
|
||||
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
#### **Core Advantages:**
|
||||
|
||||
@@ -67,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
||||
- 🎬 Unlocks **streaming platforms and mainstream AI services**
|
||||
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
||||
|
||||
## Promociones
|
||||
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
|
||||
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
|
||||
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
|
||||
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
|
||||
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
|
||||
- Desbloquea servicios de streaming y acceso a ChatGPT
|
||||
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+3
-3
@@ -50,17 +50,17 @@
|
||||
|
||||
## تبلیغات
|
||||
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
||||
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
||||
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
||||
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
||||
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
||||
|
||||
## プロモーション
|
||||
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
||||
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
||||
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
||||
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
||||
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
||||
- ストリーミングおよび ChatGPT の利用にも対応
|
||||
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
||||
|
||||
|
||||
+3
-3
@@ -51,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
||||
|
||||
## 프로모션
|
||||
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
||||
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
||||
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
||||
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
||||
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
||||
- 스트리밍 및 ChatGPT 접근 지원
|
||||
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
||||
|
||||
|
||||
+3
-3
@@ -47,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
||||
|
||||
## Продвижение
|
||||
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
||||
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
||||
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
||||
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
||||
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
||||
- Разблокировка потоковые сервисы и ChatGPT
|
||||
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+43
-43
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.7",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -49,88 +49,88 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.6.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||
"@tauri-apps/plugin-http": "~2.6.1",
|
||||
"@tauri-apps/plugin-opener": "2.5.5",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"ahooks": "^3.9.7",
|
||||
"@tauri-apps/plugin-updater": "2.12.0",
|
||||
"ahooks": "^3.10.0",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
"js-yaml": "^5.0.0",
|
||||
"foxact": "^0.3.12",
|
||||
"foxts": "^5.9.1",
|
||||
"i18next": "^26.4.2",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.25",
|
||||
"meta-json-schema": "^1.19.31",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"nanoid": "^6.0.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.5",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.4.2",
|
||||
"swr": "^2.5.1",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^9.1.1",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-es-x": "^10.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-es-x": "^10.0.1",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-sukka": "^10.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"eslint-plugin-sukka": "^10.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"globals": "^17.6.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"globals": "^17.12.0",
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
"terser": "^5.48.0",
|
||||
"lint-staged": "^17.5.1",
|
||||
"sass": "^1.104.1",
|
||||
"tar": "^7.5.22",
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "^8.0.14",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.0",
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "9.31.9",
|
||||
"ws": "8.21.3"
|
||||
},
|
||||
@@ -144,5 +144,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.5.1"
|
||||
"packageManager": "pnpm@12.8.1"
|
||||
}
|
||||
|
||||
Generated
+984
-923
File diff suppressed because it is too large
Load Diff
+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.7"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -94,12 +94,12 @@ scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.4.0"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.6.0"
|
||||
tauri-plugin-http = "2.6.1"
|
||||
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.6", version = "2.7.6", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
@@ -148,7 +148,7 @@ windows-sys.workspace = true
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tauri-plugin-updater = "2.12.0"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+158
-18
@@ -8,26 +8,81 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
|
||||
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ServiceInstallOutcome {
|
||||
Installed,
|
||||
#[cfg(any(windows, test))]
|
||||
Sidecar {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||
}
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
let result = {
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||
}
|
||||
SERVICE_MANAGER.handle_service_status(status).await
|
||||
};
|
||||
#[cfg(windows)]
|
||||
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
result
|
||||
.map(|()| ServiceInstallOutcome::Installed)
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
async fn finish_service_installation<Fallback, FallbackFuture>(
|
||||
installation: anyhow::Result<()>,
|
||||
error_code: &str,
|
||||
fallback: Fallback,
|
||||
) -> CmdResult<ServiceInstallOutcome>
|
||||
where
|
||||
Fallback: FnOnce() -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
let Err(error) = installation else {
|
||||
return Ok(ServiceInstallOutcome::Installed);
|
||||
};
|
||||
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
|
||||
else {
|
||||
return Err(proxy_aware_coded_error(&error, error_code));
|
||||
};
|
||||
let reasons = failure
|
||||
.status
|
||||
.cores
|
||||
.iter()
|
||||
.filter_map(|core| match &core.availability {
|
||||
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if reasons.is_empty() {
|
||||
return Err(proxy_aware_coded_error(&error, error_code));
|
||||
}
|
||||
let reason = reasons.join("\n");
|
||||
fallback().await.map_err(|error| {
|
||||
proxy_aware_coded_error(
|
||||
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||
"SERVICE_SIDECAR_FAILED",
|
||||
)
|
||||
})?;
|
||||
Ok(ServiceInstallOutcome::Sidecar { reason })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_service() -> CmdResult {
|
||||
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||
}
|
||||
|
||||
@@ -40,12 +95,12 @@ pub async fn uninstall_service() -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reinstall_service() -> CmdResult {
|
||||
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn repair_service() -> CmdResult {
|
||||
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||
}
|
||||
|
||||
@@ -58,13 +113,98 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> bool {
|
||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
|
||||
crate::core::CoreManager::global().get_startup_error()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_repair_notice() -> bool {
|
||||
crate::core::service::take_service_repair_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_owner_notice() -> Option<String> {
|
||||
crate::core::service::take_service_owner_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_runtime_providers() {
|
||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::{ServiceInstallOutcome, finish_service_installation};
|
||||
use clash_verge_service_ipc::{
|
||||
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
|
||||
let reason = "core path C:\\ is writable by Everyone";
|
||||
for (availability, fallback_ready) in [
|
||||
(None, false),
|
||||
(Some(CoreAvailability::Missing), false),
|
||||
(Some(CoreAvailability::DigestMismatch), false),
|
||||
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
|
||||
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
|
||||
] {
|
||||
let fallback_called = Cell::new(false);
|
||||
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
|
||||
let error = match availability {
|
||||
Some(availability) => anyhow::Error::new(InstallationVerificationError {
|
||||
status: InstallationStatus {
|
||||
service_sha256: String::new(),
|
||||
protocol: ProtocolInfo::current(),
|
||||
cores: vec![CoreInspection {
|
||||
name: "verge-mihomo.exe".into(),
|
||||
availability,
|
||||
}],
|
||||
core_busy: false,
|
||||
},
|
||||
}),
|
||||
None => anyhow::anyhow!("{reason}"),
|
||||
}
|
||||
.context("install service failed");
|
||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
|
||||
fallback_called.set(true);
|
||||
anyhow::ensure!(fallback_ready, "another core is running");
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(fallback_called.get(), rejected);
|
||||
if rejected && fallback_ready {
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(ServiceInstallOutcome::Sidecar {
|
||||
reason: format!("verge-mihomo.exe: {reason}")
|
||||
})
|
||||
);
|
||||
} else {
|
||||
let error = result.expect_err("an unavailable fallback must remain an error");
|
||||
assert_eq!(
|
||||
error.code.as_deref(),
|
||||
Some(if rejected {
|
||||
"SERVICE_SIDECAR_FAILED"
|
||||
} else {
|
||||
"SERVICE_INSTALL_FAILED"
|
||||
})
|
||||
);
|
||||
if rejected {
|
||||
assert!(error.detail.contains(reason));
|
||||
assert!(error.detail.contains("another core is running"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+133
-35
@@ -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,55 @@ 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,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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 +150,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,35 +186,46 @@ 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(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
|
||||
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> 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);
|
||||
for saved in [
|
||||
serde_yaml_ng::to_string(&confirmed)?,
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
let saved = serde_yaml_ng::to_string(&confirmed)?;
|
||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||
let settings = restarted.dns_settings_for("one");
|
||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
||||
assert!(state.enabled);
|
||||
assert!(!state.apply_to(&mut restarted));
|
||||
for (saved, source) in [
|
||||
(saved, Some("updated".into())),
|
||||
(
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
source,
|
||||
),
|
||||
] {
|
||||
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, 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 +236,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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
|
||||
| ServiceStatus::NeedsReinstall
|
||||
| ServiceStatus::InstallRequired
|
||||
| ServiceStatus::Unavailable(_)
|
||||
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
|
||||
| ServiceStatus::SidecarAllowed
|
||||
) | (
|
||||
RunningMode::Sidecar,
|
||||
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,54 +189,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,7 +381,13 @@ impl CoreManager {
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||
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.
|
||||
let prepared = async {
|
||||
@@ -578,6 +594,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 +608,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 +639,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(&reason);
|
||||
}
|
||||
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 +718,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 +1219,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;
|
||||
@@ -1427,12 +1480,18 @@ mod tests {
|
||||
ServiceStatus::NeedsReinstall,
|
||||
ServiceStatus::InstallRequired,
|
||||
ServiceStatus::Unavailable("offline".into()),
|
||||
ServiceStatus::SidecarAllowed,
|
||||
];
|
||||
for status in &allowed_statuses {
|
||||
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
|
||||
if !matches!(status, ServiceStatus::InstallRequired) {
|
||||
if !matches!(
|
||||
status,
|
||||
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
|
||||
) {
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||
} else {
|
||||
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,13 +1501,52 @@ mod tests {
|
||||
ServiceStatus::UninstallRequired,
|
||||
ServiceStatus::ReinstallRequired,
|
||||
ServiceStatus::ForceReinstallRequired,
|
||||
ServiceStatus::SidecarAllowed,
|
||||
];
|
||||
for status in &rejected_statuses {
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||
}
|
||||
}
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -74,6 +74,14 @@ impl fmt::Display for RunningMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the Core is not running, kept until a Core starts again.
|
||||
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
|
||||
pub enum CoreFailure {
|
||||
StartFailed(String),
|
||||
ServiceCoreStopped(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoreManager {
|
||||
/// The Run State this manager reports transitions to.
|
||||
@@ -87,6 +95,8 @@ pub struct CoreManager {
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
|
||||
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 +124,8 @@ impl Default for CoreManager {
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
core_readiness_state: AtomicU64::new(0),
|
||||
startup_error: parking_lot::Mutex::new(None),
|
||||
sidecar_exit: tokio::sync::Mutex::new(None),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
handoff_watcher_running: AtomicBool::new(false),
|
||||
@@ -139,6 +151,17 @@ impl CoreManager {
|
||||
self.run_state.mode_arc()
|
||||
}
|
||||
|
||||
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
|
||||
let mut startup_error = self.startup_error.lock();
|
||||
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||
*startup_error = Some(failure);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
|
||||
self.startup_error.lock().clone()
|
||||
}
|
||||
|
||||
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
|
||||
self.state
|
||||
.load()
|
||||
@@ -164,7 +187,9 @@ impl CoreManager {
|
||||
if previous != mode {
|
||||
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
|
||||
}
|
||||
let mut startup_error = self.startup_error.lock();
|
||||
self.run_state.core_started(mode);
|
||||
*startup_error = None;
|
||||
}
|
||||
|
||||
/// The Core is no longer running, for any reason.
|
||||
@@ -305,3 +330,29 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
singleton!(CoreManager, CORE_MANAGER);
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_error_tests {
|
||||
use super::{CoreFailure, CoreManager, RunningMode};
|
||||
|
||||
#[test]
|
||||
fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() {
|
||||
for mode in [RunningMode::Sidecar, RunningMode::Service] {
|
||||
let manager = CoreManager::isolated();
|
||||
manager.record_startup_error(CoreFailure::StartFailed("startup failed".into()));
|
||||
manager.core_started(mode);
|
||||
manager.core_stopped();
|
||||
assert_eq!(manager.get_startup_error(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_startup_error_survives_repeated_reads() {
|
||||
let manager = CoreManager::isolated();
|
||||
let failure = CoreFailure::StartFailed("startup failed".into());
|
||||
manager.record_startup_error(failure.clone());
|
||||
for _ in 0..2 {
|
||||
assert_eq!(manager.get_startup_error().as_ref(), Some(&failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,25 @@ 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
|
||||
.inspect_err(service::record_residual_service)?;
|
||||
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 +208,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 +253,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 +304,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 +320,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()?;
|
||||
|
||||
@@ -27,8 +27,42 @@ pub struct RealEnv;
|
||||
|
||||
impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
// The IPC client would otherwise back off for about two minutes before failing.
|
||||
#[cfg(windows)]
|
||||
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
|
||||
.await
|
||||
.context("service status probe did not finish")??
|
||||
{
|
||||
anyhow::bail!("the Windows service is not running");
|
||||
}
|
||||
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,
|
||||
@@ -36,9 +70,17 @@ impl RunStateEnv for RealEnv {
|
||||
}
|
||||
|
||||
async fn trusted_install_evidence(&self) -> Result<bool> {
|
||||
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
|
||||
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
|
||||
.await
|
||||
.context("service registration probe did not finish")?
|
||||
.context("service registration probe did not finish")??;
|
||||
// A helper that outlived its registration is a broken Service, not an absent one.
|
||||
#[cfg(unix)]
|
||||
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
|
||||
return Ok(error
|
||||
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
|
||||
.is_some());
|
||||
}
|
||||
Ok(registered)
|
||||
}
|
||||
|
||||
fn is_elevated(&self) -> bool {
|
||||
@@ -119,6 +161,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 +171,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,
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use env::FakeEnv;
|
||||
pub use env::{RealEnv, RunStateEnv};
|
||||
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
|
||||
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
|
||||
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
|
||||
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
|
||||
|
||||
use crate::core::manager::RunningMode;
|
||||
use health::StoredService;
|
||||
@@ -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,8 +24,12 @@ pub struct ServiceVersionReply {
|
||||
pub enum ServiceVersionCheck {
|
||||
Ready,
|
||||
NeedsReinstall(String),
|
||||
CoreUnavailable(String),
|
||||
}
|
||||
|
||||
/// Leads the Unavailable reason when the Service refused the approved core itself.
|
||||
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
|
||||
|
||||
/// What a live probe of the currently-installed Service told us.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CurrentServiceProbe {
|
||||
@@ -32,22 +39,30 @@ 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!("{CORE_REJECTED_PREFIX}{reason}"))
|
||||
}
|
||||
_ => 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 +78,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 +117,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 +133,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 +170,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 +186,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 +207,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 +226,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<()> {
|
||||
|
||||
+287
-504
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
|
||||
+12
-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,10 @@ mod app_init {
|
||||
cmd::set_dns_override,
|
||||
cmd::take_dns_override_notice,
|
||||
cmd::take_service_fallback_notice,
|
||||
cmd::get_core_startup_error,
|
||||
cmd::take_service_repair_notice,
|
||||
cmd::take_service_owner_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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
handle::Handle,
|
||||
hotkey::Hotkey,
|
||||
logger,
|
||||
manager::CoreFailure,
|
||||
service::{SERVICE_MANAGER, ServiceManager},
|
||||
tray::Tray,
|
||||
},
|
||||
@@ -221,6 +222,8 @@ async fn init_core_manager() -> bool {
|
||||
Ok(initialized) => initialized,
|
||||
Err(error) => {
|
||||
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
|
||||
CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}")));
|
||||
Handle::notice_message("core_start::error", "");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.7",
|
||||
"$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')
|
||||
|
||||
@@ -100,13 +100,18 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
|
||||
const item = navItemMap.get(path)
|
||||
if (!item) return null
|
||||
|
||||
// A registered sortable, even a disabled one, gets the drag-and-drop ARIA
|
||||
// attributes, so screen readers announce every item as a disabled draggable.
|
||||
if (!menuUnlocked) {
|
||||
return (
|
||||
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
|
||||
{t(item.label)}
|
||||
</LayoutItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItem
|
||||
key={item.path}
|
||||
id={item.path}
|
||||
index={index}
|
||||
disabled={!menuUnlocked}
|
||||
>
|
||||
<SortableItem key={item.path} id={item.path} index={index}>
|
||||
{(sortable) => (
|
||||
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
|
||||
{t(item.label)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Snackbar,
|
||||
Alert,
|
||||
IconButton,
|
||||
Link,
|
||||
Box,
|
||||
Stack,
|
||||
type SnackbarOrigin,
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
showNotice,
|
||||
} from '@/services/notice-service'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
|
||||
const SERVICE_PERMISSION_NOTICE =
|
||||
'settings.feedback.notifications.clashService.permissionFallback'
|
||||
const SERVICE_PERMISSION_GUIDE =
|
||||
'https://clash-verge-rev.github.io/faq/windows.html#service-core-permissions'
|
||||
const SERVICE_OWNER_NOTICE =
|
||||
'settings.feedback.notifications.clashService.appDataNotOwned'
|
||||
|
||||
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
|
||||
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
|
||||
@@ -59,6 +68,55 @@ const resolveNoticeMessage = (
|
||||
const i18n = notice.i18n
|
||||
if (!i18n) return bound(notice.message)
|
||||
|
||||
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
|
||||
return (
|
||||
<>
|
||||
{t(SERVICE_PERMISSION_NOTICE, {
|
||||
reason: t(
|
||||
'settings.feedback.notifications.clashService.permissionRejectedReason',
|
||||
),
|
||||
})}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Link
|
||||
href={SERVICE_PERMISSION_GUIDE}
|
||||
color="inherit"
|
||||
underline="always"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
|
||||
showNotice.error,
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'settings.feedback.notifications.clashService.permissionRepairGuide',
|
||||
)}
|
||||
</Link>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (i18n.key === SERVICE_OWNER_NOTICE) {
|
||||
return (
|
||||
<>
|
||||
{t(SERVICE_OWNER_NOTICE)}
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 1,
|
||||
userSelect: 'text',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{String(i18n.params?.command ?? '')}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const source = (i18n.params ?? {}) as Record<string, unknown>
|
||||
// Bound both parameters and their final interpolation.
|
||||
const params =
|
||||
@@ -132,6 +190,12 @@ const resolveNoticeCopyText = (
|
||||
notice: NoticeItem,
|
||||
t: TranslationFn,
|
||||
): string | undefined => {
|
||||
if (notice.i18n?.key === SERVICE_PERMISSION_NOTICE) {
|
||||
return extractNoticeCopyText(notice.i18n.params?.reason)
|
||||
}
|
||||
if (notice.i18n?.key === SERVICE_OWNER_NOTICE) {
|
||||
return extractNoticeCopyText(notice.i18n.params?.command)
|
||||
}
|
||||
if (
|
||||
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
|
||||
notice.i18n?.key === 'shared.feedback.notices.raw'
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getRuntimeState,
|
||||
installService,
|
||||
restartCore,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
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(),
|
||||
installService: vi.fn(),
|
||||
restartCore: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
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('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { service: 'notInstalled', mode: 'NotRunning' },
|
||||
} as ReturnType<typeof useQuery>)
|
||||
const reason = 'core path C:\\ is writable by Everyone'
|
||||
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
|
||||
vi.mocked(getRuntimeState).mockResolvedValue({
|
||||
service: 'unavailable',
|
||||
mode: 'Sidecar',
|
||||
sidecarAllowed: true,
|
||||
serviceNeedsAttention: false,
|
||||
} as RunState)
|
||||
|
||||
const dialog = ServiceMigrationDialog()
|
||||
dialog.props.onOk()
|
||||
await vi.waitFor(() => {
|
||||
expect(showNotice.warning).toHaveBeenCalledWith(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason },
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
expect(restartCore).not.toHaveBeenCalled()
|
||||
expect(showNotice.error).not.toHaveBeenCalled()
|
||||
expect(showNotice.success).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(useState).mock.results[2].value[1]).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
repairService,
|
||||
restartCore,
|
||||
type RunState,
|
||||
type ServiceInstallOutcome,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { setCacheData, useQuery } from '@/services/query-client'
|
||||
@@ -44,7 +45,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 () => {
|
||||
@@ -62,16 +80,15 @@ export const ServiceMigrationDialog = () => {
|
||||
const handleServiceAction = async () => {
|
||||
setLoading(true)
|
||||
setWorkflowIncomplete(true)
|
||||
let actionSucceeded = false
|
||||
let outcome: ServiceInstallOutcome | undefined
|
||||
try {
|
||||
if (remedy === 'install') {
|
||||
await installService()
|
||||
outcome = await installService()
|
||||
} else if (remedy === 'repair') {
|
||||
await repairService()
|
||||
outcome = await repairService()
|
||||
} else {
|
||||
await reinstallService()
|
||||
outcome = await reinstallService()
|
||||
}
|
||||
actionSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.actionFailed',
|
||||
@@ -89,10 +106,20 @@ export const ServiceMigrationDialog = () => {
|
||||
error,
|
||||
)
|
||||
}
|
||||
if (!actionSucceeded || !initialRefreshSucceeded) {
|
||||
if (!outcome || !initialRefreshSucceeded) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (outcome.status === 'sidecar') {
|
||||
setWorkflowIncomplete(false)
|
||||
setLoading(false)
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: outcome.reason },
|
||||
0,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let restartSucceeded = false
|
||||
try {
|
||||
@@ -165,8 +192,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,9 +8,11 @@ import {
|
||||
getRuntimeState,
|
||||
installService,
|
||||
patchVergeConfig,
|
||||
reinstallService,
|
||||
restartCore,
|
||||
type FailedOperation,
|
||||
type PendingFailure,
|
||||
type ServiceInstallOutcome,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
@@ -22,22 +24,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 +79,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,15 +100,29 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
|
||||
const handleFix = async () => {
|
||||
try {
|
||||
if (remedy === 'installAndRestart') {
|
||||
let outcome: ServiceInstallOutcome | undefined
|
||||
if (remedy === 'reinstallAndRestart') {
|
||||
setStep('installing')
|
||||
await installService()
|
||||
outcome = await reinstallService()
|
||||
} else if (remedy === 'installAndRestart') {
|
||||
setStep('installing')
|
||||
outcome = await installService()
|
||||
}
|
||||
if (outcome?.status === 'sidecar') {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: outcome.reason },
|
||||
0,
|
||||
)
|
||||
close()
|
||||
return
|
||||
}
|
||||
setStep('restarting')
|
||||
await restartCore()
|
||||
|
||||
const runState = await getRuntimeState()
|
||||
const usingAdminFallback =
|
||||
remedy !== 'reinstallAndRestart' &&
|
||||
getSystem() === 'windows' &&
|
||||
runState.mode === 'Sidecar' &&
|
||||
runState.isAdmin &&
|
||||
@@ -113,9 +135,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 +160,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.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@mui/material'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DialogRef } from '@/components/base'
|
||||
import { useUpdate } from '@/hooks/use-update'
|
||||
@@ -13,7 +12,6 @@ interface Props {
|
||||
|
||||
export const UpdateButton = (props: Props) => {
|
||||
const { className } = props
|
||||
const { t } = useTranslation()
|
||||
const viewerRef = useRef<DialogRef>(null)
|
||||
|
||||
const { updateInfo } = useUpdate()
|
||||
@@ -31,7 +29,7 @@ export const UpdateButton = (props: Props) => {
|
||||
className={className}
|
||||
onClick={() => viewerRef.current?.open()}
|
||||
>
|
||||
{t('shared.actions.new')}
|
||||
New
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -681,6 +681,13 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
autoComplete="new-password"
|
||||
size="small"
|
||||
sx={{ minWidth: '240px' }}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
autoCorrect: 'off',
|
||||
autoCapitalize: 'off',
|
||||
spellCheck: false,
|
||||
},
|
||||
}}
|
||||
value={ruleContent}
|
||||
required={ruleType.required ?? true}
|
||||
error={(ruleType.required ?? true) && !ruleContent}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,17 +3,18 @@ import { useCallback } from 'react'
|
||||
import { installService, restartCore } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
const executeWithErrorHandling = async (
|
||||
operation: () => Promise<void>,
|
||||
const executeWithErrorHandling = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
loadingKey: string,
|
||||
successKey?: string,
|
||||
) => {
|
||||
try {
|
||||
showNotice.info(loadingKey)
|
||||
await operation()
|
||||
const result = await operation()
|
||||
if (successKey) {
|
||||
showNotice.success(successKey)
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
throw err
|
||||
@@ -22,9 +23,19 @@ const executeWithErrorHandling = async (
|
||||
|
||||
export const useServiceInstaller = () => {
|
||||
const installServiceAndRestartCore = useCallback(async () => {
|
||||
await executeWithErrorHandling(
|
||||
const outcome = await executeWithErrorHandling(
|
||||
() => installService(),
|
||||
'settings.statuses.clashService.installing',
|
||||
)
|
||||
if (outcome.status === 'sidecar') {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: outcome.reason },
|
||||
0,
|
||||
)
|
||||
return
|
||||
}
|
||||
showNotice.success(
|
||||
'settings.feedback.notifications.clashService.installSuccess',
|
||||
)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
@@ -654,6 +655,7 @@
|
||||
"modeUpdateFailed": "تعذّر تغيير وضع الوكيل",
|
||||
"restartFailed": "تعذّر إعادة تشغيل النواة",
|
||||
"startFailed": "تعذّر تشغيل النواة",
|
||||
"serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع",
|
||||
"stopFailed": "تعذّر إيقاف النواة"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -682,8 +684,13 @@
|
||||
"geoDataUpdated": "تم تحديث البيانات الجغرافية"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "رفضت الخدمة تشغيل النواة لأن مالك مجلد بيانات التطبيق على القرص ليس حسابك (يحدث غالبًا عندما يكون مجلد المنزل على محرك أقراص خارجي). لن يؤدي إصلاح الخدمة إلى حل المشكلة. شغّل هذا الأمر في الطرفية، ثم أعد تشغيل التطبيق:",
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
|
||||
"permissionFallback": "{{reason}} تم التبديل إلى وضع Sidecar؛ يتطلب TUN صلاحيات المسؤول. اتبع الدليل للإصلاح وإعادة تثبيت الخدمة، أو شغّل التطبيق كمسؤول دون تثبيت الخدمة.",
|
||||
"permissionWritableReason": "يمكن للحسابات العادية تعديل ملفات النواة أو أحد المجلدات الأصلية، لذا رفضت الخدمة تشغيل النواة بصلاحيات مرتفعة.",
|
||||
"permissionRejectedReason": "تعذّر على الخدمة التأكد من أن ملفات النواة ومجلداتها آمنة للتشغيل بصلاحيات مرتفعة.",
|
||||
"permissionRepairGuide": "عرض خطوات الإصلاح",
|
||||
"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": {
|
||||
@@ -654,6 +655,7 @@
|
||||
"modeUpdateFailed": "Proxy-Modus konnte nicht geändert werden",
|
||||
"restartFailed": "Kern konnte nicht neu gestartet werden",
|
||||
"startFailed": "Kern konnte nicht gestartet werden",
|
||||
"serviceCoreStopped": "Der vom Dienst ausgeführte Kern wurde unerwartet beendet",
|
||||
"stopFailed": "Kern konnte nicht gestoppt werden"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -682,8 +684,13 @@
|
||||
"geoDataUpdated": "Geo-Daten wurden aktualisiert"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "Der Dienst hat den Start des Kerns abgelehnt, weil der App-Datenordner auf dem Datenträger nicht Ihrem Konto gehört (häufig, wenn der Benutzerordner auf einem externen Laufwerk liegt). Eine Reparatur des Dienstes behebt das nicht. Führen Sie diesen Befehl im Terminal aus und starten Sie die App danach neu:",
|
||||
"installSuccess": "Service erfolgreich installiert",
|
||||
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
|
||||
"permissionFallback": "{{reason}} Zum Sidecar-Modus gewechselt; TUN benötigt Administratorrechte. Reparieren Sie den Dienst gemäß Anleitung und installieren Sie ihn neu, oder starten Sie die App als Administrator ohne Dienstinstallation.",
|
||||
"permissionWritableReason": "Normale Konten können Kerndateien oder übergeordnete Ordner ändern. Daher verweigert der Dienst die Ausführung mit erhöhten Rechten.",
|
||||
"permissionRejectedReason": "Der Dienst konnte nicht bestätigen, dass die Kerndateien und Ordner für die Ausführung mit erhöhten Rechten sicher sind.",
|
||||
"permissionRepairGuide": "Reparaturanleitung öffnen",
|
||||
"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": {
|
||||
@@ -654,6 +655,7 @@
|
||||
"modeUpdateFailed": "Failed to change proxy mode",
|
||||
"restartFailed": "Failed to restart the core",
|
||||
"startFailed": "Failed to start the core",
|
||||
"serviceCoreStopped": "The service's core stopped unexpectedly",
|
||||
"stopFailed": "Failed to stop the core"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -682,8 +684,13 @@
|
||||
"geoDataUpdated": "GeoData Updated"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "The service refused to start the core because, on disk, the app data folder is not owned by your account (common when the home folder is on an external drive). Repairing the service will not fix this. Run this command in a terminal, then restart the app:",
|
||||
"installSuccess": "Service Installed Successfully",
|
||||
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
|
||||
"permissionFallback": "{{reason}} Switched to normal mode (Sidecar); TUN requires administrator privileges. Follow the guide to fix and reinstall the service. If you prefer not to fix it or do not know how, run the app as administrator without installing the service.",
|
||||
"permissionWritableReason": "Ordinary accounts can modify the core files or a parent folder, so the service refused to run the core with elevated privileges.",
|
||||
"permissionRejectedReason": "The service could not confirm that the core files and folders are safe to run with elevated privileges.",
|
||||
"permissionRepairGuide": "View repair instructions",
|
||||
"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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user