Compare commits

..
15 Commits
Author SHA1 Message Date
wonfen b057bd964c fix clippy 2026-09-26 00:00:50 +08:00
wonfen 897a117dc5 chore(deps): bump tauri-plugin-mihomo for provider update timeouts
Pick up tauri-plugin-mihomo@ba8434c: provider updates now wait out
mihomo's 20s fetch instead of failing at 5s with "error sending request",
so slow subscriptions update and real failures show mihomo's own error.

Refs #8046
2026-09-25 16:09:57 +08:00
wonfen 6a752994cf fix(service): fail fast when the Windows service is stopped
A registered but stopped service made every version probe back off for
about two minutes, blocking startup before the window and core (#8059).
Check the SCM first; a service that is starting, or not yet started this
boot, still goes through the IPC retries.
2026-09-25 11:55:01 +08:00
wonfen 32e5d5bda3 chore(deps): pin clash-verge-service-ipc to v2.7.4
Replace the local path pin from fbc4b516 with the release tag, which
ships the installer group lookup fix (#8043) along with the residual
helper (#8031) and app data owner (#8026) service changes.
2026-09-25 09:09:14 +08:00
wonfen daae17a40c fix(service): surface a service core that stopped for good
When the service watchdog gave up or the core vanished, the owner
monitor marked the core stopped without telling the user, and on macOS
left the system proxy on the dead mixed port (#8029).

- On SameOwnerFailure, clear the proxy through the helper while the
  session is still ours; displaced or unreachable owners still leave the
  machine-wide setting alone.
- Retain the stop as a core failure with its own message, recorded
  under the lifecycle lock and shown now or on next focus; a running
  core resets the notice dedup.
- Log each watchdog restart the service reports, since launchd discards
  the service's own log.
2026-09-25 00:06:59 +08:00
wonfen 1f140dfd3a fix(service): show how to reclaim an app data root the service refuses
When the service refuses the app data root because another uid owns it
(code 1012), show a sticky notice with the chown command for this
account. Repairing the service cannot fix this.

Refs #8026
2026-09-24 22:10:42 +08:00
wonfen 04d74ac48f fix(service): offer repair when a residual helper blocks Sidecar
A macOS helper left running without IPC made Sidecar refuse to start
while the service counted as not installed, so no repair was offered
(#8031, #8043). Treat such a helper as install evidence and record the
Sidecar refusal as unavailable so the repair dialog appears.
2026-09-24 20:24:40 +08:00
wonfen 78bf79cc09 chore: update changelog 2026-09-24 18:52:24 +08:00
wonfen 61c33c6113 fix(core): retain startup diagnostics until recovery
Show unresolved startup failures when the window becomes visible and clear them when either core mode starts successfully. Cover recovery followed by exit and WebView recreation, use neutral service permission wording, and document the Windows startup fix.
2026-09-24 18:43:28 +08:00
wonfen ae4f416766 fix(service): explain core security failures and link repair guidance 2026-09-24 18:43:27 +08:00
wonfen fbc4b516b8 fix(service): fall back after installation verification rejects cores 2026-09-24 18:43:26 +08:00
3456de1fb6 fix(rules): disable auto-capitalization in rule content input (#8042)
* fix(rules): disable auto-capitalization in rule content input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rules): pass input hints to the rule content input element

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-09-24 15:49:19 +08:00
Tunglies f9ef9d78cb chore: bump version to 2.5.6 2026-09-24 15:25:05 +08:00
Tungliesand❤是纱雾酱哟~ 3458c9b075 fix(layout): restore "New" label in update button
Closes #8000

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-24 15:23:35 +08:00
❤是纱雾酱哟~ afc32f568a chore(deps): Bump pnpm and cargo dependencies (#8022)
- Bump dependencies via `pnpm update`
- Bump dependencies via `cargo upgrade`
  - Incompatible dependencies already ignored
- Align Tauri-related dependencies version between `pnpm` and `cargo`
- Drop explicit forbidden of application downgrades

Assisted-by: vscode-copilot-chat:copilot:gpt-5.6-luna codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-23 07:03:15 +00:00
44 changed files with 1883 additions and 1026 deletions
Generated
+10 -10
View File
@@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.5.5"
version = "2.5.6"
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.4"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.4#b4f66bd5a365875562a622065d189e3514654b7f"
dependencies = [
"anyhow",
"kode-bridge",
@@ -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
View File
@@ -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"
+14 -19
View File
@@ -1,36 +1,31 @@
## v2.5.5
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🐧 Linux**
**🍎 macOS**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
+50
View File
@@ -1,5 +1,44 @@
## 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 +84,11 @@
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
@@ -66,6 +110,11 @@
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
@@ -90,6 +139,7 @@
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
+42 -42
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.5",
"version": "2.5.6",
"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"
},
+923 -862
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.5"
version = "2.5.6"
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.4", version = "2.7.4", 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 }
+152 -17
View File
@@ -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
}
@@ -62,14 +117,94 @@ pub fn take_service_fallback_notice() -> bool {
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"));
}
}
}
}
}
+15 -4
View File
@@ -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 {
@@ -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));
+49
View File
@@ -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));
}
}
}
+3 -1
View File
@@ -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()?;
+18 -2
View File
@@ -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 {
+1 -3
View File
@@ -47,9 +47,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!("approved core was rejected: {reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
+123 -30
View File
@@ -5,7 +5,7 @@ use crate::{
core::{
CoreManager,
handle::Handle,
manager::RunningMode,
manager::{CoreFailure, RunningMode},
owner_identity::current_owner_credentials,
proxy_control,
runstate::{
@@ -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;
@@ -40,6 +41,7 @@ static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
@@ -55,6 +57,19 @@ 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 +242,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 +255,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);
@@ -805,6 +839,13 @@ fn record_service_start_refusal<E: RunStateEnv>(
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 +885,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,
@@ -873,6 +918,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_OWNER_NOTICE.lock().take();
logging!(
info,
Type::Service,
@@ -915,7 +961,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 +979,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 +1512,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 +1538,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 +1558,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 +1577,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 +1621,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 +1636,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 +1672,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 +1693,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 +1721,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 +2245,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);
}
+2
View File
@@ -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,
+3
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.5",
"version": "2.5.6",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+64
View File
@@ -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 -3
View File
@@ -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}
+15 -4
View File
@@ -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',
)
+6
View File
@@ -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": "تم إلغاء تثبيت الخدمة بنجاح"
},
+6
View File
@@ -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"
},
+6
View File
@@ -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"
},
+6
View File
@@ -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"
},
+6
View File
@@ -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": "سرویس با موفقیت حذف نصب شد"
},
+6
View File
@@ -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"
},
+6
View File
@@ -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": "サービスのアンインストールに成功しました。"
},
+6
View File
@@ -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": "서비스가 제거되었습니다"
},
+6
View File
@@ -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": "Служба успешно удалена"
},
+6
View File
@@ -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ı"
},
+6
View File
@@ -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": "Сервис уңышлы салдырылды"
},
+6
View File
@@ -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": "已成功卸载服务"
},
+6
View File
@@ -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,144 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { takeDnsOverrideNotice, takeServiceRepairNotice } from '@/services/cmds'
import {
type RunState,
getCoreStartupError,
takeDnsOverrideNotice,
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(),
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
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() },
}))
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 () => {
+14 -1
View File
@@ -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,6 +96,25 @@ 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) => {
+20 -3
View File
@@ -190,10 +190,23 @@ export async function takeServiceFallbackNotice() {
return invoke<boolean>('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 +467,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 +480,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 () => {
+1 -1
View File
@@ -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)
+6
View File
@@ -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',
+6
View File
@@ -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
}