mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 05:40: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 |
@@ -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.5"
|
||||
version = "2.5.7"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -1223,8 +1223,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clash_verge_service_ipc"
|
||||
version = "2.7.3"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.3#29251d968e1a38fac1de0ebe9d6d69402bea83b4"
|
||||
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",
|
||||
|
||||
+1
-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"
|
||||
|
||||
+18
-19
@@ -1,36 +1,35 @@
|
||||
## v2.5.5
|
||||
## v2.5.7
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
||||
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
||||
|
||||
**🐧 Linux**
|
||||
**🍎 macOS**
|
||||
|
||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
||||
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
||||
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
## 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>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
@@ -45,6 +117,11 @@
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
@@ -66,6 +143,11 @@
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
@@ -90,6 +172,7 @@
|
||||
|
||||
- 优化 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.5",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.5"
|
||||
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.3", version = "2.7.3", 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 }
|
||||
|
||||
+153
-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,18 +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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +108,7 @@ impl DnsOverrideState {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProfileDnsSettings {
|
||||
pub enabled: bool,
|
||||
// Force-enable confirmation is valid only for the current app session.
|
||||
#[serde(skip)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub confirmation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -195,7 +194,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
profile_dns_settings: [(
|
||||
@@ -208,15 +207,22 @@ mod tests {
|
||||
.into(),
|
||||
..IVerge::default()
|
||||
};
|
||||
let settings = confirmed.dns_settings_for("one");
|
||||
assert!(DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation).enabled);
|
||||
for saved in [
|
||||
serde_yaml_ng::to_string(&confirmed)?,
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
let 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 settings = restarted.dns_settings_for("one");
|
||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
||||
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!(!restarted.dns_settings_for("one").enabled);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -381,7 +385,9 @@ impl CoreManager {
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
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 {
|
||||
@@ -672,7 +678,7 @@ impl CoreManager {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
self.rollback_failed_start().await;
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
crate::core::service::notify_service_fallback(&reason);
|
||||
}
|
||||
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
|
||||
}
|
||||
@@ -1474,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,7 +1501,6 @@ mod tests {
|
||||
ServiceStatus::UninstallRequired,
|
||||
ServiceStatus::ReinstallRequired,
|
||||
ServiceStatus::ForceReinstallRequired,
|
||||
ServiceStatus::SidecarAllowed,
|
||||
];
|
||||
for status in &rejected_statuses {
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||
|
||||
@@ -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,7 @@ 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。
|
||||
@@ -115,6 +124,7 @@ 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")]
|
||||
@@ -141,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()
|
||||
@@ -166,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.
|
||||
@@ -307,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,9 @@ 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?;
|
||||
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()?;
|
||||
|
||||
@@ -27,6 +27,14 @@ 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| {
|
||||
@@ -62,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,9 @@ pub enum ServiceVersionCheck {
|
||||
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 {
|
||||
@@ -47,9 +50,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
|
||||
return match &reply.core {
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
|
||||
ServiceVersionCheck::CoreUnavailable(format!(
|
||||
"approved core was rejected: {reason}; reinstall the service to repair it"
|
||||
))
|
||||
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
|
||||
}
|
||||
_ => ServiceVersionCheck::CoreUnavailable(
|
||||
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
|
||||
|
||||
+165
-39
@@ -5,12 +5,12 @@ use crate::{
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::Handle,
|
||||
manager::RunningMode,
|
||||
manager::{CoreFailure, RunningMode},
|
||||
owner_identity::current_owner_credentials,
|
||||
proxy_control,
|
||||
runstate::{
|
||||
OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError,
|
||||
RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
||||
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||
},
|
||||
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
||||
tray::Tray,
|
||||
@@ -21,8 +21,9 @@ use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{
|
||||
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
|
||||
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
|
||||
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
||||
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
@@ -38,23 +39,50 @@ use std::{
|
||||
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Why the Service was unavailable when the core fell back to Sidecar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
|
||||
pub enum ServiceFallbackNotice {
|
||||
Unavailable,
|
||||
CoreRejected(String),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
||||
pub(crate) fn notify_service_fallback(reason: &str) {
|
||||
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
|
||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
||||
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
||||
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
||||
ServiceFallbackNotice::CoreRejected(reason)
|
||||
} else {
|
||||
ServiceFallbackNotice::Unavailable
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_repair_notice() -> bool {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the command that gives the app data root back to this account.
|
||||
pub(crate) fn take_service_owner_notice() -> Option<String> {
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take()
|
||||
}
|
||||
|
||||
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
|
||||
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
|
||||
return None;
|
||||
};
|
||||
let path = credentials.app_data_dir.replace('\'', r"'\''");
|
||||
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
#[derive(Clone)]
|
||||
@@ -227,7 +255,7 @@ fn macos_service_install_marker_exists() -> std::io::Result<bool> {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
|
||||
use windows_service::{
|
||||
Error as WindowsServiceError,
|
||||
service::ServiceAccess,
|
||||
@@ -240,17 +268,36 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS,
|
||||
) {
|
||||
Ok(service) => {
|
||||
drop(service);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(service) => Ok(Some(service)),
|
||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
||||
Ok(false)
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error).context("failed to inspect Windows service registration"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
Ok(open_registered_service()?.is_some())
|
||||
}
|
||||
|
||||
/// Whether IPC cannot succeed until the service is started again. A service that is starting, or
|
||||
/// that the SCM has not started yet this boot, is left to the IPC retries, which wait for it.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn service_stopped() -> Result<bool> {
|
||||
use windows_service::service::{ServiceExitCode, ServiceState};
|
||||
|
||||
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||
let Some(service) = open_registered_service()? else {
|
||||
return Ok(true);
|
||||
};
|
||||
let status = service
|
||||
.query_status()
|
||||
.context("failed to query Windows service status")?;
|
||||
Ok(status.current_state == ServiceState::Stopped
|
||||
&& status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
|
||||
@@ -799,12 +846,22 @@ fn record_service_start_refusal<E: RunStateEnv>(
|
||||
store: &RunStateStore<E>,
|
||||
refusal: ServiceStartRefusal,
|
||||
) -> anyhow::Error {
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning {
|
||||
// A proxy clear failure is about the system network settings; repairing the service cannot fix it.
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning
|
||||
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16
|
||||
{
|
||||
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
|
||||
}
|
||||
refusal.into()
|
||||
}
|
||||
|
||||
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
|
||||
pub(super) fn record_residual_service(error: &anyhow::Error) {
|
||||
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
|
||||
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -844,6 +901,10 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::repair_required", "");
|
||||
}
|
||||
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
||||
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
||||
Handle::notice_message("service_core::app_data_not_owned", "");
|
||||
}
|
||||
start_owner_monitor();
|
||||
return Err(record_service_start_refusal(
|
||||
&RUN_STATE,
|
||||
@@ -871,8 +932,9 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take();
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -915,7 +977,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
|
||||
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
}
|
||||
let err_msg = response.message;
|
||||
bail!(err_msg);
|
||||
@@ -933,7 +995,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
|
||||
let response = response.context("无法连接到Clash Verge Service")?;
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
}
|
||||
bail!(response.message);
|
||||
}
|
||||
@@ -1466,9 +1528,11 @@ struct OwnerRecoveryPolicy {
|
||||
reset_system_proxy: bool,
|
||||
}
|
||||
|
||||
const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
/// The macOS proxy is machine-wide and only the helper may write it, for the session it still
|
||||
/// honours; a displaced or unreachable owner must leave it alone.
|
||||
const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
OwnerRecoveryPolicy {
|
||||
reset_system_proxy: !is_macos,
|
||||
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1554,7 @@ fn start_owner_monitor() {
|
||||
AsyncHandler::spawn(move || async move {
|
||||
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
|
||||
let mut watch = OwnerWatch::new();
|
||||
let mut core_restarts = None;
|
||||
loop {
|
||||
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
||||
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
||||
@@ -1509,7 +1574,10 @@ fn start_owner_monitor() {
|
||||
break;
|
||||
}
|
||||
|
||||
let sample = read_owner_sample().await;
|
||||
let (sample, status) = read_owner_sample().await;
|
||||
if let Some(status) = &status {
|
||||
log_core_restarts(&mut core_restarts, status);
|
||||
}
|
||||
let mut step = watch.observe(sample);
|
||||
if matches!(step, OwnerStep::VerifyTransport) {
|
||||
if watch.just_became_sustained() {
|
||||
@@ -1525,15 +1593,41 @@ fn start_owner_monitor() {
|
||||
}
|
||||
|
||||
if let OwnerStep::Recover(reason) = step {
|
||||
recover_after_owner_loss(generation, reason).await;
|
||||
recover_after_owner_loss(generation, reason, status.as_ref()).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Service logs its watchdog restarts where the app cannot read them.
|
||||
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
|
||||
if seen.is_some_and(|seen| status.restart_count > seen) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"service restarted the core ({} restarts so far); last exit: {}",
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
*seen = Some(status.restart_count);
|
||||
}
|
||||
|
||||
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
|
||||
let detail = format!(
|
||||
"service state {:?}, {} restarts, last exit: {}",
|
||||
status.service_state,
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
|
||||
);
|
||||
logging!(error, Type::Service, "service core stopped: {detail}");
|
||||
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
||||
Handle::notice_message("core_start::error", "");
|
||||
}
|
||||
|
||||
/// Samples ownership, treating every unusable reply as unreadable.
|
||||
async fn read_owner_sample() -> OwnerSample {
|
||||
async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
|
||||
let response = match current_owner_credentials() {
|
||||
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
|
||||
Err(error) => Err(error),
|
||||
@@ -1543,12 +1637,12 @@ async fn read_owner_sample() -> OwnerSample {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
logging!(debug, Type::Service, "service owner status was unreadable: {error:#}");
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
}
|
||||
};
|
||||
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
return OwnerSample::NotActive;
|
||||
return (OwnerSample::NotActive, None);
|
||||
}
|
||||
if response.code != 0 {
|
||||
logging!(
|
||||
@@ -1558,24 +1652,25 @@ async fn read_owner_sample() -> OwnerSample {
|
||||
response.code,
|
||||
response.message
|
||||
);
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
}
|
||||
let Some(status) = response.data else {
|
||||
logging!(debug, Type::Service, "service owner status omitted data");
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
};
|
||||
|
||||
// A session that no longer matches is another owner's, whatever the flags say.
|
||||
if !session_matches_active_status(status.is_active, status.active_generation) {
|
||||
return OwnerSample::NotActive;
|
||||
return (OwnerSample::NotActive, None);
|
||||
}
|
||||
|
||||
OwnerSample::Status {
|
||||
let sample = OwnerSample::Status {
|
||||
is_active: status.is_active,
|
||||
desired_core_should_be_running: status.desired_core_should_be_running,
|
||||
service_state: status.service_state,
|
||||
core_pid: status.core_pid,
|
||||
}
|
||||
};
|
||||
(sample, Some(status))
|
||||
}
|
||||
|
||||
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
|
||||
@@ -1593,7 +1688,12 @@ pub(crate) fn owner_monitor_generation() -> u64 {
|
||||
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) {
|
||||
/// `status` is the sample that led to the recovery, if any; a failed core is reported from it.
|
||||
async fn recover_after_owner_loss(
|
||||
generation: u64,
|
||||
reason: OwnerRecoveryReason,
|
||||
status: Option<&ServiceStatusSnapshot>,
|
||||
) {
|
||||
let manager = CoreManager::global();
|
||||
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
|
||||
return;
|
||||
@@ -1609,6 +1709,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason)
|
||||
return;
|
||||
}
|
||||
recover_after_owner_loss_while_locked(reason).await;
|
||||
// Still under the lifecycle lock, so a later start is the one that clears it.
|
||||
if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) {
|
||||
report_service_core_stopped(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
|
||||
@@ -1633,13 +1737,15 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
||||
);
|
||||
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
||||
proxy_control::stop_guard().await;
|
||||
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
||||
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||
clear_proxy_after_owner_loss().await;
|
||||
}
|
||||
clear_active_service_session();
|
||||
CoreManager::global().core_stopped();
|
||||
}
|
||||
|
||||
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||
return;
|
||||
}
|
||||
|
||||
async fn clear_proxy_after_owner_loss() {
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=3 {
|
||||
match proxy_control::clear().await {
|
||||
@@ -2155,13 +2261,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_recovery_never_resets_machine_wide_proxy() {
|
||||
fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() {
|
||||
for reason in [
|
||||
OwnerRecoveryReason::Displaced,
|
||||
OwnerRecoveryReason::SameOwnerFailure,
|
||||
OwnerRecoveryReason::TransportFailure,
|
||||
] {
|
||||
assert!(!owner_recovery_policy(reason, true).reset_system_proxy);
|
||||
assert_eq!(
|
||||
owner_recovery_policy(reason, true).reset_system_proxy,
|
||||
reason == OwnerRecoveryReason::SameOwnerFailure
|
||||
);
|
||||
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
|
||||
}
|
||||
|
||||
@@ -2219,6 +2328,23 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_clear_refusal_does_not_ask_for_service_repair() {
|
||||
let store = fake_store();
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let _ = super::record_service_start_refusal(
|
||||
&store,
|
||||
super::ServiceStartRefusal {
|
||||
code: clash_verge_service_ipc::ServiceErrorCode::ProxyClearFailed as u16,
|
||||
core_path: "/development/service-core/verge-mihomo".into(),
|
||||
message: "SystemConfiguration operation failed: lock preferences (status 3002)".into(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(store.state().service_usable());
|
||||
assert!(!store.state().service_needs_attention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
||||
let store = fake_store();
|
||||
|
||||
@@ -171,7 +171,9 @@ 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,
|
||||
|
||||
@@ -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 @@
|
||||
{
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.7",
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RunState } from '@/services/cmds'
|
||||
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'
|
||||
@@ -14,8 +20,14 @@ vi.mock('@mui/material', () => ({ Alert: 'div' }))
|
||||
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
|
||||
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
|
||||
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||
vi.mock('@/services/cmds', () => ({ getRuntimeState: vi.fn() }))
|
||||
vi.mock('@/services/notice-service', () => ({ showNotice: {} }))
|
||||
vi.mock('@/services/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(),
|
||||
@@ -47,6 +59,37 @@ it.each([
|
||||
},
|
||||
)
|
||||
|
||||
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' },
|
||||
|
||||
@@ -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'
|
||||
@@ -79,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',
|
||||
@@ -106,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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
restartCore,
|
||||
type FailedOperation,
|
||||
type PendingFailure,
|
||||
type ServiceInstallOutcome,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
@@ -99,12 +100,22 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
|
||||
const handleFix = async () => {
|
||||
try {
|
||||
let outcome: ServiceInstallOutcome | undefined
|
||||
if (remedy === 'reinstallAndRestart') {
|
||||
setStep('installing')
|
||||
await reinstallService()
|
||||
outcome = await reinstallService()
|
||||
} else if (remedy === 'installAndRestart') {
|
||||
setStep('installing')
|
||||
await installService()
|
||||
outcome = await installService()
|
||||
}
|
||||
if (outcome?.status === 'sidecar') {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: outcome.reason },
|
||||
0,
|
||||
)
|
||||
close()
|
||||
return
|
||||
}
|
||||
setStep('restarting')
|
||||
await restartCore()
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "تعذّر تغيير وضع الوكيل",
|
||||
"restartFailed": "تعذّر إعادة تشغيل النواة",
|
||||
"startFailed": "تعذّر تشغيل النواة",
|
||||
"serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع",
|
||||
"stopFailed": "تعذّر إيقاف النواة"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "تم تحديث البيانات الجغرافية"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "رفضت الخدمة تشغيل النواة لأن مالك مجلد بيانات التطبيق على القرص ليس حسابك (يحدث غالبًا عندما يكون مجلد المنزل على محرك أقراص خارجي). لن يؤدي إصلاح الخدمة إلى حل المشكلة. شغّل هذا الأمر في الطرفية، ثم أعد تشغيل التطبيق:",
|
||||
"installSuccess": "تم تثبيت الخدمة بنجاح",
|
||||
"permissionFallback": "{{reason}} تم التبديل إلى وضع Sidecar؛ يتطلب TUN صلاحيات المسؤول. اتبع الدليل للإصلاح وإعادة تثبيت الخدمة، أو شغّل التطبيق كمسؤول دون تثبيت الخدمة.",
|
||||
"permissionWritableReason": "يمكن للحسابات العادية تعديل ملفات النواة أو أحد المجلدات الأصلية، لذا رفضت الخدمة تشغيل النواة بصلاحيات مرتفعة.",
|
||||
"permissionRejectedReason": "تعذّر على الخدمة التأكد من أن ملفات النواة ومجلداتها آمنة للتشغيل بصلاحيات مرتفعة.",
|
||||
"permissionRepairGuide": "عرض خطوات الإصلاح",
|
||||
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.",
|
||||
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
|
||||
},
|
||||
|
||||
@@ -655,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": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -655,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": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "No se pudo cambiar el modo de proxy",
|
||||
"restartFailed": "No se pudo reiniciar el núcleo",
|
||||
"startFailed": "No se pudo iniciar el núcleo",
|
||||
"serviceCoreStopped": "El núcleo ejecutado por el servicio se detuvo inesperadamente",
|
||||
"stopFailed": "No se pudo detener el núcleo"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData actualizado"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "El servicio se negó a iniciar el núcleo porque, en el disco, la carpeta de datos de la aplicación no pertenece a su cuenta (es habitual cuando la carpeta de inicio está en una unidad externa). Reparar el servicio no lo soluciona. Ejecute este comando en una terminal y luego reinicie la aplicación:",
|
||||
"installSuccess": "Servicio instalado con éxito",
|
||||
"permissionFallback": "{{reason}} Se ha cambiado al modo Sidecar; TUN requiere permisos de administrador. Siga la guía para reparar y reinstalar el servicio, o ejecute la aplicación como administrador sin instalar el servicio.",
|
||||
"permissionWritableReason": "Las cuentas normales pueden modificar los archivos del núcleo o una carpeta superior, por lo que el servicio rechazó ejecutarlo con privilegios elevados.",
|
||||
"permissionRejectedReason": "El servicio no pudo confirmar que los archivos y carpetas del núcleo sean seguros para ejecutarlos con privilegios elevados.",
|
||||
"permissionRepairGuide": "Ver instrucciones de reparación",
|
||||
"sidecarFallback": "El servicio no pudo iniciar el núcleo. Se ha cambiado al modo Sidecar; el servicio sigue sin estar disponible.",
|
||||
"uninstallSuccess": "Servicio desinstalado con éxito"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "تغییر حالت پروکسی ناموفق بود",
|
||||
"restartFailed": "راهاندازی مجدد هسته ناموفق بود",
|
||||
"startFailed": "راهاندازی هسته ناموفق بود",
|
||||
"serviceCoreStopped": "هستهای که سرویس اجرا میکرد بهطور غیرمنتظره متوقف شد",
|
||||
"stopFailed": "توقف هسته ناموفق بود"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData بهروزرسانی شد"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "سرویس از اجرای هسته خودداری کرد، زیرا مالک پوشه دادههای برنامه روی دیسک، حساب شما نیست (معمولاً وقتی پوشه خانگی روی درایو خارجی باشد). تعمیر سرویس این مشکل را حل نمیکند. این دستور را در ترمینال اجرا کنید و سپس برنامه را دوباره اجرا کنید:",
|
||||
"installSuccess": "سرویس با موفقیت نصب شد",
|
||||
"permissionFallback": "{{reason}} به حالت Sidecar تغییر یافت؛ TUN به دسترسی مدیر نیاز دارد. طبق راهنما مشکل را برطرف و سرویس را دوباره نصب کنید، یا برنامه را بدون نصب سرویس با دسترسی مدیر اجرا کنید.",
|
||||
"permissionWritableReason": "حسابهای عادی میتوانند فایلهای هسته یا پوشهٔ والد را تغییر دهند، بنابراین سرویس از اجرای هسته با دسترسی بالا خودداری کرد.",
|
||||
"permissionRejectedReason": "سرویس نتوانست ایمن بودن فایلها و پوشههای هسته را برای اجرا با دسترسی بالا تأیید کند.",
|
||||
"permissionRepairGuide": "مشاهدهٔ راهنمای رفع مشکل",
|
||||
"sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه مییابد؛ سرویس همچنان در دسترس نیست.",
|
||||
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Gagal mengubah mode proxy",
|
||||
"restartFailed": "Gagal memulai ulang core",
|
||||
"startFailed": "Gagal memulai core",
|
||||
"serviceCoreStopped": "Core yang dijalankan layanan berhenti secara tak terduga",
|
||||
"stopFailed": "Gagal menghentikan core"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData Diperbarui"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "Layanan menolak memulai inti karena, di disk, folder data aplikasi tidak dimiliki oleh akun Anda (sering terjadi jika folder beranda berada di drive eksternal). Memperbaiki layanan tidak akan mengatasinya. Jalankan perintah ini di terminal, lalu mulai ulang aplikasi:",
|
||||
"installSuccess": "Layanan Berhasil Diinstal",
|
||||
"permissionFallback": "{{reason}} Beralih ke mode Sidecar; TUN memerlukan hak administrator. Ikuti panduan untuk memperbaiki dan menginstal ulang layanan, atau jalankan aplikasi sebagai administrator tanpa menginstal layanan.",
|
||||
"permissionWritableReason": "Akun biasa dapat mengubah berkas inti atau folder induknya, sehingga layanan menolak menjalankan inti dengan hak istimewa.",
|
||||
"permissionRejectedReason": "Layanan tidak dapat memastikan bahwa berkas dan folder inti aman dijalankan dengan hak istimewa.",
|
||||
"permissionRepairGuide": "Lihat panduan perbaikan",
|
||||
"sidecarFallback": "Layanan tidak dapat memulai inti. Beralih ke mode Sidecar; layanan masih tidak tersedia.",
|
||||
"uninstallSuccess": "Layanan Berhasil Dicopot"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "プロキシモードの変更に失敗しました",
|
||||
"restartFailed": "コアの再起動に失敗しました",
|
||||
"startFailed": "コアの起動に失敗しました",
|
||||
"serviceCoreStopped": "サービスで実行中のコアが予期せず停止しました",
|
||||
"stopFailed": "コアの停止に失敗しました"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoDataが更新されました。"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "サービスがコアの起動を拒否しました。ディスク上のアプリデータフォルダーの所有者が現在のアカウントではありません(ホームフォルダーが外付けドライブにある場合によく起こります)。サービスを修復しても解決しません。ターミナルで次のコマンドを実行してから、アプリを再起動してください:",
|
||||
"installSuccess": "サービスのインストールに成功しました。",
|
||||
"permissionFallback": "{{reason}} 通常モード(Sidecar)に切り替えました。TUN には管理者権限が必要です。手順に従って修復しサービスを再インストールするか、サービスをインストールせずにアプリを管理者として実行してください。",
|
||||
"permissionWritableReason": "一般アカウントがコアファイルまたは上位フォルダーを変更できるため、サービスは高い権限での実行を拒否しました。",
|
||||
"permissionRejectedReason": "コアファイルとフォルダーを高い権限で安全に実行できるか確認できませんでした。",
|
||||
"permissionRepairGuide": "修復方法を見る",
|
||||
"sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。",
|
||||
"uninstallSuccess": "サービスのアンインストールに成功しました。"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "프록시 모드를 변경하지 못했습니다",
|
||||
"restartFailed": "코어를 재시작하지 못했습니다",
|
||||
"startFailed": "코어를 시작하지 못했습니다",
|
||||
"serviceCoreStopped": "서비스에서 실행 중인 코어가 예기치 않게 중지되었습니다",
|
||||
"stopFailed": "코어를 중지하지 못했습니다"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData가 업데이트되었습니다"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "서비스가 코어 시작을 거부했습니다. 디스크상의 앱 데이터 폴더 소유자가 현재 계정이 아닙니다(홈 폴더가 외장 드라이브에 있을 때 흔히 발생). 서비스를 복구해도 해결되지 않습니다. 터미널에서 다음 명령을 실행한 후 앱을 다시 시작하세요:",
|
||||
"installSuccess": "서비스가 설치되었습니다",
|
||||
"permissionFallback": "{{reason}} 일반 모드(Sidecar)로 전환했습니다. TUN에는 관리자 권한이 필요합니다. 안내에 따라 수정 후 서비스를 다시 설치하거나, 서비스 설치 없이 앱을 관리자 권한으로 실행하세요.",
|
||||
"permissionWritableReason": "일반 계정이 코어 파일이나 상위 폴더를 수정할 수 있어 서비스가 높은 권한으로 실행하는 것을 거부했습니다.",
|
||||
"permissionRejectedReason": "서비스가 코어 파일과 폴더를 높은 권한으로 안전하게 실행할 수 있는지 확인하지 못했습니다.",
|
||||
"permissionRepairGuide": "해결 방법 보기",
|
||||
"sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.",
|
||||
"uninstallSuccess": "서비스가 제거되었습니다"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Не удалось изменить режим прокси",
|
||||
"restartFailed": "Не удалось перезапустить ядро",
|
||||
"startFailed": "Не удалось запустить ядро",
|
||||
"serviceCoreStopped": "Ядро, запущенное службой, неожиданно остановилось",
|
||||
"stopFailed": "Не удалось остановить ядро"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "Файлы GeoData обновлены"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "Служба отказалась запускать ядро: на диске папка данных приложения принадлежит не вашей учётной записи (часто бывает, если домашняя папка находится на внешнем диске). Восстановление службы это не исправит. Выполните в терминале следующую команду и перезапустите приложение:",
|
||||
"installSuccess": "Служба успешно установлена",
|
||||
"permissionFallback": "{{reason}} Выполнен переход в режим Sidecar; TUN требует прав администратора. Исправьте проблему по инструкции и переустановите службу либо запустите приложение от имени администратора без установки службы.",
|
||||
"permissionWritableReason": "Обычные учётные записи могут изменять файлы ядра или родительскую папку, поэтому служба отказалась запускать ядро с повышенными правами.",
|
||||
"permissionRejectedReason": "Служба не смогла подтвердить безопасность файлов и папок ядра для запуска с повышенными правами.",
|
||||
"permissionRepairGuide": "Инструкция по исправлению",
|
||||
"sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.",
|
||||
"uninstallSuccess": "Служба успешно удалена"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Proxy modu değiştirilemedi",
|
||||
"restartFailed": "Çekirdek yeniden başlatılamadı",
|
||||
"startFailed": "Çekirdek başlatılamadı",
|
||||
"serviceCoreStopped": "Hizmetin çalıştırdığı çekirdek beklenmedik şekilde durdu",
|
||||
"stopFailed": "Çekirdek durdurulamadı"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData Güncellendi"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "Hizmet çekirdeği başlatmayı reddetti; çünkü diskte uygulama veri klasörünün sahibi hesabınız değil (ana klasör harici bir sürücüdeyse sık görülür). Hizmeti onarmak bunu düzeltmez. Bu komutu bir terminalde çalıştırın, ardından uygulamayı yeniden başlatın:",
|
||||
"installSuccess": "Hizmet Başarıyla Kuruldu",
|
||||
"permissionFallback": "{{reason}} Sidecar moduna geçildi; TUN yönetici yetkisi gerektirir. Kılavuza göre sorunu düzeltip hizmeti yeniden yükleyin veya hizmeti yüklemeden uygulamayı yönetici olarak çalıştırın.",
|
||||
"permissionWritableReason": "Normal hesaplar çekirdek dosyalarını veya üst klasörü değiştirebildiği için hizmet, çekirdeği yükseltilmiş yetkilerle çalıştırmayı reddetti.",
|
||||
"permissionRejectedReason": "Hizmet, çekirdek dosya ve klasörlerinin yükseltilmiş yetkilerle güvenle çalıştırılabileceğini doğrulayamadı.",
|
||||
"permissionRepairGuide": "Düzeltme adımlarını görüntüle",
|
||||
"sidecarFallback": "Hizmet çekirdeği başlatamadı. Sidecar moduna geçildi; hizmet hâlâ kullanılamıyor.",
|
||||
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "Прокси режимын үзгәртеп булмады",
|
||||
"restartFailed": "Ядрону яңадан башлап булмады",
|
||||
"startFailed": "Ядрону башлап булмады",
|
||||
"serviceCoreStopped": "Хезмәт эшләткән ядро көтмәгәндә туктады",
|
||||
"stopFailed": "Ядрону туктатып булмады"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "GeoData яңартылды"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "Хезмәт төшне эшләтеп җибәрүдән баш тартты, чөнки дисктагы кушымта мәгълүматлары папкасының хуҗасы сезнең хисап язмасы түгел (өй папкасы тышкы дискта булганда еш очрый). Хезмәтне төзәтү моны хәл итмәячәк. Бу боерыкны терминалда башкарыгыз, аннары кушымтаны яңадан җибәрегез:",
|
||||
"installSuccess": "Сервис уңышлы урнаштырылды",
|
||||
"permissionFallback": "{{reason}} Sidecar режимына күчелде; TUN өчен администратор хокуклары кирәк. Күрсәтмәләр буенча төзәтеп, хезмәтне яңадан урнаштырыгыз яки хезмәтне урнаштырмыйча кушымтаны администратор булып эшләтегез.",
|
||||
"permissionWritableReason": "Гади хисап язмалары төш файлларын яки өске папканы үзгәртә ала, шуңа күрә хезмәт төшне югары хокуклар белән эшләтүдән баш тартты.",
|
||||
"permissionRejectedReason": "Хезмәт төш файлларын һәм папкаларын югары хокуклар белән имин эшләтеп булачагын раслый алмады.",
|
||||
"permissionRepairGuide": "Төзәтү күрсәтмәләрен карау",
|
||||
"sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.",
|
||||
"uninstallSuccess": "Сервис уңышлы салдырылды"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "切换代理模式失败",
|
||||
"restartFailed": "重启内核失败",
|
||||
"startFailed": "启动内核失败",
|
||||
"serviceCoreStopped": "服务运行的内核意外停止",
|
||||
"stopFailed": "停止内核失败"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "已更新 GeoData"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "服务拒绝启动内核:磁盘上应用数据目录的所有者不是当前账户(常见于用户目录位于外置磁盘)。修复服务无法解决此问题,请在终端运行以下命令,然后重启软件:",
|
||||
"installSuccess": "已成功安装服务",
|
||||
"permissionFallback": "{{reason}} 已切换到普通模式(Sidecar),普通权限下无法使用 TUN。可按文档修复后重装服务;不想或不会修复时,可直接以管理员身份运行软件,无需安装服务。",
|
||||
"permissionWritableReason": "内核文件或上级文件夹允许普通账户修改,服务为防止内核被替换而拒绝高权限运行。",
|
||||
"permissionRejectedReason": "服务无法确认内核文件及所在目录是否安全,已拒绝高权限运行。",
|
||||
"permissionRepairGuide": "查看修复方法",
|
||||
"sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。",
|
||||
"uninstallSuccess": "已成功卸载服务"
|
||||
},
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"modeUpdateFailed": "切換代理模式失敗",
|
||||
"restartFailed": "重啟內核失敗",
|
||||
"startFailed": "啟動內核失敗",
|
||||
"serviceCoreStopped": "服務執行的內核意外停止",
|
||||
"stopFailed": "停止內核失敗"
|
||||
},
|
||||
"clashService": {
|
||||
@@ -683,7 +684,12 @@
|
||||
"geoDataUpdated": "已更新 GeoData"
|
||||
},
|
||||
"clashService": {
|
||||
"appDataNotOwned": "服務拒絕啟動核心:磁碟上應用程式資料目錄的擁有者不是目前帳戶(常見於使用者目錄位於外接磁碟)。修復服務無法解決此問題,請在終端機執行以下命令,然後重新啟動軟體:",
|
||||
"installSuccess": "已成功安裝服務",
|
||||
"permissionFallback": "{{reason}} 已切換至一般模式(Sidecar),一般權限下無法使用 TUN。可依文件修復後重新安裝服務;不想或不會修復時,可直接以系統管理員身分執行軟體,無需安裝服務。",
|
||||
"permissionWritableReason": "核心檔案或上層資料夾允許一般帳戶修改,服務為防止核心被替換而拒絕以高權限執行。",
|
||||
"permissionRejectedReason": "服務無法確認核心檔案及所在目錄是否安全,已拒絕以高權限執行。",
|
||||
"permissionRepairGuide": "查看修復方法",
|
||||
"sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。",
|
||||
"uninstallSuccess": "已成功解除安裝服務"
|
||||
},
|
||||
|
||||
@@ -1,36 +1,145 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { takeDnsOverrideNotice, takeServiceRepairNotice } from '@/services/cmds'
|
||||
import {
|
||||
type RunState,
|
||||
getCoreStartupError,
|
||||
takeDnsOverrideNotice,
|
||||
takeServiceFallbackNotice,
|
||||
takeServiceRepairNotice,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { revalidateQueries } from '@/services/query-client'
|
||||
import { requestService } from '@/services/service-request'
|
||||
|
||||
import { handleNoticeMessage } from '../utils/notification-handlers'
|
||||
|
||||
import { useLayoutEvents } from './use-layout-events'
|
||||
|
||||
let handleNoticeMessage: typeof import('../utils/notification-handlers').handleNoticeMessage
|
||||
|
||||
const nativeWindow = vi.hoisted(() => ({
|
||||
isVisible: vi.fn().mockResolvedValue(true),
|
||||
isMinimized: vi.fn().mockResolvedValue(false),
|
||||
onFocusChanged: vi.fn().mockResolvedValue(() => {}),
|
||||
}))
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => nativeWindow,
|
||||
}))
|
||||
vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
|
||||
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
|
||||
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getCoreStartupError: vi.fn().mockResolvedValue(null),
|
||||
takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null),
|
||||
takeDnsOverrideNotice: vi.fn(),
|
||||
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
|
||||
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
|
||||
takeServiceFallbackNotice: vi.fn().mockResolvedValue(null),
|
||||
takeServiceRepairNotice: vi.fn().mockResolvedValue(false),
|
||||
takeServiceOwnerNotice: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
vi.mock('@/services/service-request', () => ({ requestService: vi.fn() }))
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { info: vi.fn() },
|
||||
showNotice: { info: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({
|
||||
revalidateQueries: vi.fn(),
|
||||
setCacheData: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
;({ handleNoticeMessage } = await import('../utils/notification-handlers'))
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(null)
|
||||
nativeWindow.isVisible.mockResolvedValue(true)
|
||||
nativeWindow.isMinimized.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('does not replay a recovered startup error after WebView recreation', async () => {
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail: 'startup failed',
|
||||
})
|
||||
handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledOnce()
|
||||
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(null)
|
||||
vi.resetModules()
|
||||
const recreated = await import('../utils/notification-handlers')
|
||||
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['hidden', false, false],
|
||||
['minimized', true, true],
|
||||
] as const)(
|
||||
'keeps a %s-window startup error until the window is restored',
|
||||
async (_state, visible, minimized) => {
|
||||
const detail = 'startup failed'
|
||||
nativeWindow.isVisible.mockResolvedValue(visible)
|
||||
nativeWindow.isMinimized.mockResolvedValue(minimized)
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail,
|
||||
})
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
const [handlers, onSubscribed] =
|
||||
vi.mocked(subscribeVergeEvents).mock.calls[0]
|
||||
onSubscribed?.()
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).not.toHaveBeenCalled()
|
||||
nativeWindow.isVisible.mockResolvedValue(true)
|
||||
nativeWindow.isMinimized.mockResolvedValue(false)
|
||||
const onFocus = nativeWindow.onFocusChanged.mock.calls[0][0]
|
||||
onFocus({ payload: true })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
detail,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('delivers an early core startup failure once after listeners mount', async () => {
|
||||
const detail = 'could not open core execution mutex: access denied'
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue({
|
||||
kind: 'startFailed',
|
||||
detail,
|
||||
})
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
|
||||
onSubscribed?.()
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
detail,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the same core failure again after the core has started in between', async () => {
|
||||
const { useLayoutEvents } = await import('./use-layout-events')
|
||||
const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const
|
||||
vi.mocked(getCoreStartupError).mockResolvedValue(failure)
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0]
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// The core started and stopped again before the window handled either event.
|
||||
handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState)
|
||||
handlers['verge://notice-message']?.(['core_start::error', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(showNotice.error).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drains a DNS notice after listeners mount without duplicating its live event', async () => {
|
||||
@@ -76,3 +185,26 @@ it('offers service reinstallation for a startup path refusal even before listene
|
||||
reason: 'serviceLocationRefused',
|
||||
})
|
||||
})
|
||||
|
||||
it('explains a startup core rejection instead of the generic fallback notice', async () => {
|
||||
const reason =
|
||||
'approved core was rejected: core path "C:\\" has an untrusted write ACE'
|
||||
vi.mocked(takeServiceFallbackNotice)
|
||||
.mockResolvedValueOnce({ kind: 'coreRejected', reason })
|
||||
.mockResolvedValue(null)
|
||||
|
||||
useLayoutEvents(([status, message]) => {
|
||||
handleNoticeMessage(status, message, (key) => key, vi.fn())
|
||||
})
|
||||
|
||||
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
|
||||
onSubscribed?.()
|
||||
handlers['verge://notice-message']?.(['service_core::sidecar_fallback', ''])
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason },
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { revalidateProfiles } from '@/hooks/use-profiles'
|
||||
@@ -6,6 +7,8 @@ import type { RunState } from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { revalidateQueries, setCacheData } from '@/services/query-client'
|
||||
|
||||
import { forgetShownStartupError } from '../utils/notification-handlers'
|
||||
|
||||
export const useLayoutEvents = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
@@ -31,7 +34,7 @@ export const useLayoutEvents = (
|
||||
void revalidateProfiles()
|
||||
}
|
||||
|
||||
return subscribeVergeEvents(
|
||||
const unsubscribe = subscribeVergeEvents(
|
||||
{
|
||||
'profile-changed': handleProfileChanged,
|
||||
'verge://refresh-profiles': () => void revalidateProfiles(),
|
||||
@@ -57,6 +60,7 @@ export const useLayoutEvents = (
|
||||
// Transitions carry the full run-state snapshot, so write it directly to cache.
|
||||
'verge://run-state-changed': (payload) => {
|
||||
void setCacheData<RunState>(runStateQueryKey, payload)
|
||||
if (payload.mode !== 'NotRunning') forgetShownStartupError()
|
||||
},
|
||||
'verge://notice-message': handleNotice,
|
||||
},
|
||||
@@ -67,7 +71,16 @@ export const useLayoutEvents = (
|
||||
handleNotice(['enhance::discarded_keys', ''])
|
||||
handleNotice(['service_core::sidecar_fallback', ''])
|
||||
handleNotice(['service_core::repair_required', ''])
|
||||
handleNotice(['service_core::app_data_not_owned', ''])
|
||||
handleNotice(['core_start::error', ''])
|
||||
},
|
||||
)
|
||||
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
|
||||
if (payload) handleNotice(['core_start::error', ''])
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
}
|
||||
}, [handleNotice])
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
|
||||
import {
|
||||
getCoreStartupError,
|
||||
takeDiscardedKeysNotice,
|
||||
takeDnsOverrideNotice,
|
||||
takeServiceFallbackNotice,
|
||||
takeServiceOwnerNotice,
|
||||
takeServiceRepairNotice,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { hideNotice, showNotice } from '@/services/notice-service'
|
||||
import { requestService } from '@/services/service-request'
|
||||
|
||||
type NavigateFunction = (path: string, options?: any) => void
|
||||
type TranslateFunction = (key: string) => string
|
||||
|
||||
let shownStartupError: string | null = null
|
||||
// Reads can settle out of order; only the newest may act, and a reset counts as one.
|
||||
let startupErrorReads = 0
|
||||
let settledStartupErrorRead = 0
|
||||
let shownOwnerNotice: number | null = null
|
||||
|
||||
/** A running core has resolved every failure shown before it. */
|
||||
export const forgetShownStartupError = () => {
|
||||
shownStartupError = null
|
||||
settledStartupErrorRead = ++startupErrorReads
|
||||
}
|
||||
|
||||
export const handleNoticeMessage = (
|
||||
status: string,
|
||||
msg: string,
|
||||
@@ -31,6 +47,41 @@ export const handleNoticeMessage = (
|
||||
showNotice.error(msg)
|
||||
},
|
||||
'set_config::error': () => showNotice.error(msg),
|
||||
'core_start::error': () => {
|
||||
const read = ++startupErrorReads
|
||||
void getCoreStartupError()
|
||||
.then(async (failure) => {
|
||||
if (read < settledStartupErrorRead) return
|
||||
settledStartupErrorRead = read
|
||||
if (!failure) {
|
||||
shownStartupError = null
|
||||
return
|
||||
}
|
||||
const window = getCurrentWindow()
|
||||
const [visible, minimized] = await Promise.all([
|
||||
window.isVisible(),
|
||||
window.isMinimized(),
|
||||
])
|
||||
const shown = `${failure.kind}:${failure.detail}`
|
||||
if (
|
||||
read === settledStartupErrorRead &&
|
||||
visible &&
|
||||
!minimized &&
|
||||
shownStartupError !== shown
|
||||
) {
|
||||
shownStartupError = shown
|
||||
showNotice.error(
|
||||
failure.kind === 'serviceCoreStopped'
|
||||
? 'settings.feedback.errors.clash.serviceCoreStopped'
|
||||
: 'settings.feedback.errors.clash.startFailed',
|
||||
failure.detail,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to read the pending core startup error', error)
|
||||
})
|
||||
},
|
||||
'service_core::repair_required': () => {
|
||||
void takeServiceRepairNotice()
|
||||
.then((pending) => {
|
||||
@@ -45,10 +96,35 @@ export const handleNoticeMessage = (
|
||||
)
|
||||
})
|
||||
},
|
||||
'service_core::app_data_not_owned': () => {
|
||||
void takeServiceOwnerNotice()
|
||||
.then((command) => {
|
||||
if (command) {
|
||||
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
|
||||
shownOwnerNotice = showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.appDataNotOwned',
|
||||
{ command },
|
||||
0,
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to read the pending service owner notice',
|
||||
error,
|
||||
)
|
||||
})
|
||||
},
|
||||
'service_core::sidecar_fallback': () => {
|
||||
void takeServiceFallbackNotice()
|
||||
.then((pending) => {
|
||||
if (pending) {
|
||||
.then((notice) => {
|
||||
if (notice?.kind === 'coreRejected') {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: notice.reason },
|
||||
0,
|
||||
)
|
||||
} else if (notice) {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.sidecarFallback',
|
||||
)
|
||||
|
||||
+25
-4
@@ -186,14 +186,31 @@ export async function takeDnsOverrideNotice() {
|
||||
return invoke<boolean>('take_dns_override_notice')
|
||||
}
|
||||
|
||||
export type ServiceFallbackNotice =
|
||||
| { kind: 'unavailable' }
|
||||
| { kind: 'coreRejected'; reason: string }
|
||||
|
||||
export async function takeServiceFallbackNotice() {
|
||||
return invoke<boolean>('take_service_fallback_notice')
|
||||
return invoke<ServiceFallbackNotice | null>('take_service_fallback_notice')
|
||||
}
|
||||
|
||||
export interface CoreFailure {
|
||||
kind: 'startFailed' | 'serviceCoreStopped'
|
||||
detail: string
|
||||
}
|
||||
|
||||
export async function getCoreStartupError() {
|
||||
return invoke<CoreFailure | null>('get_core_startup_error')
|
||||
}
|
||||
|
||||
export async function takeServiceRepairNotice() {
|
||||
return invoke<boolean>('take_service_repair_notice')
|
||||
}
|
||||
|
||||
export async function takeServiceOwnerNotice() {
|
||||
return invoke<string | null>('take_service_owner_notice')
|
||||
}
|
||||
|
||||
export async function takeDiscardedKeysNotice() {
|
||||
return invoke<string | null>('take_discarded_keys_notice')
|
||||
}
|
||||
@@ -454,8 +471,12 @@ export const getAppUptime = async () => {
|
||||
return invoke<number>('get_app_uptime')
|
||||
}
|
||||
|
||||
export type ServiceInstallOutcome =
|
||||
| { status: 'installed' }
|
||||
| { status: 'sidecar'; reason: string }
|
||||
|
||||
export const installService = async () => {
|
||||
return invoke<void>('install_service')
|
||||
return invoke<ServiceInstallOutcome>('install_service')
|
||||
}
|
||||
|
||||
export const uninstallService = async () => {
|
||||
@@ -463,11 +484,11 @@ export const uninstallService = async () => {
|
||||
}
|
||||
|
||||
export const reinstallService = async () => {
|
||||
return invoke<void>('reinstall_service')
|
||||
return invoke<ServiceInstallOutcome>('reinstall_service')
|
||||
}
|
||||
|
||||
export const repairService = async () => {
|
||||
return invoke<void>('repair_service')
|
||||
return invoke<ServiceInstallOutcome>('repair_service')
|
||||
}
|
||||
|
||||
export const continueWithSidecar = async () => {
|
||||
|
||||
@@ -72,7 +72,7 @@ const localVersionNormalized = normalizeVersion(appVersion)
|
||||
export const checkUpdateSafe = async (
|
||||
options?: CheckOptions,
|
||||
): Promise<Update | null> => {
|
||||
const result = await check({ ...(options ?? {}), allowDowngrades: false })
|
||||
const result = await check(options ?? {})
|
||||
if (!result) return null
|
||||
|
||||
const remoteVersion = resolveRemoteVersion(result)
|
||||
|
||||
@@ -785,6 +785,7 @@ export const translationKeys = [
|
||||
'settings.feedback.errors.clash.modeUpdateFailed',
|
||||
'settings.feedback.errors.clash.restartFailed',
|
||||
'settings.feedback.errors.clash.startFailed',
|
||||
'settings.feedback.errors.clash.serviceCoreStopped',
|
||||
'settings.feedback.errors.clash.stopFailed',
|
||||
'settings.feedback.errors.clashService.installFailed',
|
||||
'settings.feedback.errors.clashService.uninstallFailed',
|
||||
@@ -803,7 +804,12 @@ export const translationKeys = [
|
||||
'settings.feedback.notifications.clash.changeSuccess',
|
||||
'settings.feedback.notifications.clash.changeFailed',
|
||||
'settings.feedback.notifications.clash.geoDataUpdated',
|
||||
'settings.feedback.notifications.clashService.appDataNotOwned',
|
||||
'settings.feedback.notifications.clashService.installSuccess',
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
'settings.feedback.notifications.clashService.permissionWritableReason',
|
||||
'settings.feedback.notifications.clashService.permissionRejectedReason',
|
||||
'settings.feedback.notifications.clashService.permissionRepairGuide',
|
||||
'settings.feedback.notifications.clashService.sidecarFallback',
|
||||
'settings.feedback.notifications.clashService.uninstallSuccess',
|
||||
'settings.feedback.notifications.updater.withClashProxySuccess',
|
||||
|
||||
@@ -815,6 +815,7 @@ export interface TranslationResources {
|
||||
configUpdateFailed: string
|
||||
modeUpdateFailed: string
|
||||
restartFailed: string
|
||||
serviceCoreStopped: string
|
||||
startFailed: string
|
||||
stopFailed: string
|
||||
}
|
||||
@@ -844,7 +845,12 @@ export interface TranslationResources {
|
||||
versionUpdated: string
|
||||
}
|
||||
clashService: {
|
||||
appDataNotOwned: string
|
||||
installSuccess: string
|
||||
permissionFallback: string
|
||||
permissionRejectedReason: string
|
||||
permissionRepairGuide: string
|
||||
permissionWritableReason: string
|
||||
sidecarFallback: string
|
||||
uninstallSuccess: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user