Compare commits

..
Author SHA1 Message Date
TungliesandCopilot afc85f2d55 refactor(linux): package the service uninstaller wrapper through Tauri files
Place both uninstaller files in /usr/bin directly from the deb and rpm
`files` maps instead of having prebuild copy the wrapper over the
uninstaller sidecar and move the real binary to /usr/lib.

/usr/bin/clash-verge-service-uninstall stays the entry point that
released rpm %preun scripts and the app call, and is now
packages/linux/service-uninstall.sh itself. The real uninstaller is
packaged as /usr/bin/clash-verge-service-uninstall.real.

`files` sources cannot vary by target, while release builds run one
prebuild per Linux target, so prebuild stores the uninstaller without
the target-triple suffix. It is no longer an externalBin.

Refs #8515

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-09 23:46:30 +08:00
TungliesandCopilot d09f78f9b1 feat(linux): reinstall an existing Service during package upgrades
The deb and rpm packages never refreshed an installed Service, and the
previous package's prerm/%preun uninstalls it unconditionally. When the
Service unit existed before the upgrade, the new postinst/%post now
reinstalls it with its group and restores disabled, enabled-runtime,
masked-runtime and stopped states. First installs and explicit removals
never add a Service.

rpm runs the new %post before the old %preun. %post records the
transaction process (pid and start time) in /run, and
/usr/bin/clash-verge-service-uninstall becomes a wrapper that skips only
that %preun call and otherwise runs the real uninstaller, now under
/usr/lib/clash-verge-rev. deb has no such ordering: an upgrade from a
released version still loses the Service, and later deb upgrades reinstall
it in place only once the prerm upgrade guard from #8240 ships.

The scripts exit 0, because a failing postinst or %post cannot restore the
Service. On failure they write /run/clash-verge-service-upgrade instead;
package script output never reaches the GUI. At startup the app offers a
reinstall if the Service is not ready and warns if its previous state was
not restored. Both notices replay when a window subscribes, and the marker
is acknowledged only after both are taken. In-app updates stop a
Service-run core and hold the core lifecycle until the package manager
exits, including after the 30-second startup timeout.

Refs #8515

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-09 21:28:46 +08:00
65 changed files with 927 additions and 329 deletions
-21
View File
@@ -34,24 +34,3 @@ jobs:
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
with:
version: v1.0.1
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Install frontend dependencies
env:
HUSKY: 0
run: pnpm install --frozen-lockfile
Generated
+14 -70
View File
@@ -159,9 +159,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.4"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [
"rustversion",
]
@@ -1120,7 +1120,7 @@ dependencies = [
"percent-encoding",
"reqwest 0.13.5",
"reqwest_dav",
"rfd 0.18.0",
"rfd",
"runas",
"rustls",
"scopeguard",
@@ -2404,9 +2404,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.11"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
@@ -3241,9 +3241,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.5"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -4070,9 +4070,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.25.0"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1d73c59a56771343712b22ea5615092c612c16ba291c0979e3105dfeec4890"
checksum = "490750560fa7851fbbf5d3ab1a8750f09350ed361ed8aed85b1e1e40587812ee"
dependencies = [
"sha2 0.11.1",
]
@@ -4538,7 +4538,6 @@ dependencies = [
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
"objc2-uniform-type-identifiers",
]
[[package]]
@@ -4760,16 +4759,6 @@ dependencies = [
"objc2-user-notifications",
]
[[package]]
name = "objc2-uniform-type-identifiers"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
@@ -5313,12 +5302,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]]
name = "polonius-the-crab"
version = "0.5.0"
@@ -5951,31 +5934,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rfd"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4b9d4857b10297ffb0f64cae711e983a7aeaee561848d56d429d3858bd8ed93"
dependencies = [
"block2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-uniform-type-identifiers",
"percent-encoding",
"pollster",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -7469,7 +7427,7 @@ checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5"
dependencies = [
"log",
"raw-window-handle",
"rfd 0.17.2",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -10074,20 +10032,6 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -10126,9 +10070,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.4.2"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [
"aes",
"bzip2",
@@ -10156,9 +10100,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.8"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
[[package]]
name = "zmij"
+4 -1
View File
@@ -28,7 +28,6 @@
- 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
**🍎 macOS**
@@ -53,4 +52,8 @@
- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
**🐧 Linux**
- 优化 Linux 升级安装时自动重装已安装的服务并保留启停设置,重装失败时提示
</details>
+2 -2
View File
@@ -121,7 +121,7 @@
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.44.2",
"knip": "6.44.1",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
@@ -133,7 +133,7 @@
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.2.0",
"ws": "8.22.2"
"ws": "8.22.1"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
+97 -92
View File
@@ -396,8 +396,8 @@ importers:
specifier: ^2.7.0
version: 2.7.0
knip:
specifier: 6.44.2
version: 6.44.2
specifier: 6.44.1
version: 6.44.1
lint-staged:
specifier: ^17.5.1
version: 17.5.1
@@ -432,8 +432,8 @@ importers:
specifier: 10.2.0
version: 10.2.0(supports-color@7.2.0)(yauzl@2.10.0)
ws:
specifier: 8.22.2
version: 8.22.2
specifier: 8.22.1
version: 8.22.1
packages:
@@ -1595,128 +1595,131 @@ packages:
'@octokit/types@18.0.0':
resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==}
'@oxc-parser/binding-android-arm-eabi@0.156.0':
resolution: {integrity: sha512-RMq7DKNzEp1T51QKNLTn0NryNEt2IYfh7mZBlIdi2Ju52ltOEDMeIXv0XBpA6dL7j7ChjlcovtGcc1Jv4qMipg==}
'@oxc-parser/binding-android-arm-eabi@0.155.0':
resolution: {integrity: sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxc-parser/binding-android-arm64@0.156.0':
resolution: {integrity: sha512-mwkWKhOBpmh8fWc3m4MiLsmEAtx0YuHmvyo9ff557cnb3QZ2kt2eauSz75wCGIpiZG3gUsJ4WwzEjm4R4/BHig==}
'@oxc-parser/binding-android-arm64@0.155.0':
resolution: {integrity: sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.156.0':
resolution: {integrity: sha512-6pGR5fUszM9M4eBBapqpGjCRJolFwuCldx8bRQjqXyM09cQysBTWVF2Ttg4UNJVxYSaw5RRINbveCOOCrsuGvA==}
'@oxc-parser/binding-darwin-arm64@0.155.0':
resolution: {integrity: sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.156.0':
resolution: {integrity: sha512-M9QghL7uCAqMKjYJv1rDGblfEvkeQu3QzTPd7Uwo16gG3uO7m9ikVQ9OL8FZTnKzU4vbCEby6kTlEkqN+JeEMA==}
'@oxc-parser/binding-darwin-x64@0.155.0':
resolution: {integrity: sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.156.0':
resolution: {integrity: sha512-lX0ANxSwAOrpOT7NHB+YsrBqDJ8sOVon5m7GS9eQtkuUeNzQMMGpFsJLsStawTbrdwOF5KDVxgPWcKmt/DIZUQ==}
'@oxc-parser/binding-freebsd-x64@0.155.0':
resolution: {integrity: sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.156.0':
resolution: {integrity: sha512-iPgYpasRcqk7X3TGncQg7B+6RotDvG8weug0UrgF+FXLf4KmaNDyT91BKONlqbHcNgK9Nryp7AEZGuQn32fP6g==}
'@oxc-parser/binding-linux-arm-gnueabihf@0.155.0':
resolution: {integrity: sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.156.0':
resolution: {integrity: sha512-sJqJDEZevtkEfoxrXfaRTkSt6Itckr549cIfBbs11UgXv8bFyqQsXTQa6iKm8mTiWYmCDDFJf7TLnqxcbqzPOw==}
'@oxc-parser/binding-linux-arm-musleabihf@0.155.0':
resolution: {integrity: sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.156.0':
resolution: {integrity: sha512-mrQs5OdRiqKskRuWEORPKT6YGIFhrpVvCvL9hqVaIh60EzgR4qjPvSsV6hWZcOfyWHBTbdEvfnDJPh/eQlxPRQ==}
'@oxc-parser/binding-linux-arm64-gnu@0.155.0':
resolution: {integrity: sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.156.0':
resolution: {integrity: sha512-ZtPqgCU3+wpU7dvX7YbaEiw71r0UdguzuPhcx4PIEZSs4XFqc6F5wXCtNdb6M7m3DMErlxrwosryoXrzYOY/Gg==}
'@oxc-parser/binding-linux-arm64-musl@0.155.0':
resolution: {integrity: sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.156.0':
resolution: {integrity: sha512-ZuGRT/adReZZJn3PYNneb1HQIOH0iIZ2fzFgC1/XJYpMQ5KsRKmWIEzy3wEFsUwIOTomCmnmTZ/zbFYo9z9VeA==}
'@oxc-parser/binding-linux-ppc64-gnu@0.155.0':
resolution: {integrity: sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.156.0':
resolution: {integrity: sha512-T+iWCeAOoEAJpHqr6/pogHIv98g9p8Ctccysm/h962hG4ayX5OL2c0Ih4oYe+JuFndLXy/OASvwMlTPe76sIiw==}
'@oxc-parser/binding-linux-riscv64-gnu@0.155.0':
resolution: {integrity: sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.156.0':
resolution: {integrity: sha512-wL55bI2kVKfwdxScbw60zVgzAqbQbmE5zpSPWPZoZ9RSG8egm6ITE/UWE5mDWlglxz1QElJCcUbpaJCtHD11BQ==}
'@oxc-parser/binding-linux-riscv64-musl@0.155.0':
resolution: {integrity: sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.156.0':
resolution: {integrity: sha512-+D+JilhLqwrDLODYEl4qtovVwgxKALb23FkAWBiPvgmUWYgjCV83KKuwwxZJM8GCdKlNA1vt3iKtow8HUFjXtg==}
'@oxc-parser/binding-linux-s390x-gnu@0.155.0':
resolution: {integrity: sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.156.0':
resolution: {integrity: sha512-jRfwpN4xKrsEpC+SgrUPY5k0mINYZn7aLrt22I3uQFoumiSgHgsLYgS9f84ntbkvSydH4HiwGuDa2b1XCHaJzw==}
'@oxc-parser/binding-linux-x64-gnu@0.155.0':
resolution: {integrity: sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.156.0':
resolution: {integrity: sha512-P/ajnypgLmxmuRRyCIZcp2o4lOwmCaXYH5+eqDTcCW+MCTI+csKSw/Zc6GB67jZoVa4ey3q1jXEt7S60adBEMQ==}
'@oxc-parser/binding-linux-x64-musl@0.155.0':
resolution: {integrity: sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.156.0':
resolution: {integrity: sha512-WSN6rQZZc9KqgZ4lMfjVDvqKhVOCjey/YEZIPisGXmwBqwgWOzru207dyiH50LHcf1n9LO2oceGk/id6RL9v0g==}
'@oxc-parser/binding-openharmony-arm64@0.155.0':
resolution: {integrity: sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxc-parser/binding-win32-arm64-msvc@0.156.0':
resolution: {integrity: sha512-l21Tz6tjraGBC3JefVeJZ9pojD0fCiB62PydAv+i9nrqJX7z/pDmyDNYLamDr/PRcQ4UQbFwkSnz7NKBB9UMLw==}
'@oxc-parser/binding-win32-arm64-msvc@0.155.0':
resolution: {integrity: sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-ia32-msvc@0.156.0':
resolution: {integrity: sha512-CUq0ZFl/RODqPiIgfNNSamkpTj5x8AjBTkR/y1DNVIQiTGnmWqjHcn3o08ZW3q/LgkI4g9G9g6UirmmQFvuOZw==}
'@oxc-parser/binding-win32-ia32-msvc@0.155.0':
resolution: {integrity: sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.156.0':
resolution: {integrity: sha512-dcpx5Y1x2v/cs7U/q3SZMvmxfyXjVvqC86jMuw2ZZUbHDd7W2cW7uSMQfg3GLVaE68Su3H9xd/Pc4v6QNyeSRg==}
'@oxc-parser/binding-win32-x64-msvc@0.155.0':
resolution: {integrity: sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.155.0':
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
'@oxc-project/types@0.156.0':
resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
@@ -3855,8 +3858,8 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
knip@6.44.2:
resolution: {integrity: sha512-6URO7pnqtOnyvSsXfW6+8C9j1lfcicxUCysXO0RgMtHTZ96HqCXh29op8iabeQW5JeIc8dJWwzSdJVWDokeScQ==}
knip@6.44.1:
resolution: {integrity: sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -4227,8 +4230,8 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
oxc-parser@0.156.0:
resolution: {integrity: sha512-7JhjgOfZjrIFJH5TlHrpRua864Si5GvwrPr5+eIRCY2sszhKXo6pyhP0HQKOGTtfO4UowwZ72XTGba0+lZphOQ==}
oxc-parser@0.155.0:
resolution: {integrity: sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.24.2:
@@ -5064,8 +5067,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.22.2:
resolution: {integrity: sha512-/rEZ2ejown2N5gxVTHUu9bVVC7ttu0/Tgzf5hbTOixR3Fs7mbbXgn2DbdbcKgJOmpQMtrIUWivtYuVMnp/W/yA==}
ws@8.22.1:
resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -6483,63 +6486,65 @@ snapshots:
dependencies:
'@octokit/openapi-types': 29.0.1
'@oxc-parser/binding-android-arm-eabi@0.156.0':
'@oxc-parser/binding-android-arm-eabi@0.155.0':
optional: true
'@oxc-parser/binding-android-arm64@0.156.0':
'@oxc-parser/binding-android-arm64@0.155.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.156.0':
'@oxc-parser/binding-darwin-arm64@0.155.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.156.0':
'@oxc-parser/binding-darwin-x64@0.155.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.156.0':
'@oxc-parser/binding-freebsd-x64@0.155.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.156.0':
'@oxc-parser/binding-linux-arm-gnueabihf@0.155.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.156.0':
'@oxc-parser/binding-linux-arm-musleabihf@0.155.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.156.0':
'@oxc-parser/binding-linux-arm64-gnu@0.155.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.156.0':
'@oxc-parser/binding-linux-arm64-musl@0.155.0':
optional: true
'@oxc-parser/binding-linux-ppc64-gnu@0.156.0':
'@oxc-parser/binding-linux-ppc64-gnu@0.155.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.156.0':
'@oxc-parser/binding-linux-riscv64-gnu@0.155.0':
optional: true
'@oxc-parser/binding-linux-riscv64-musl@0.156.0':
'@oxc-parser/binding-linux-riscv64-musl@0.155.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.156.0':
'@oxc-parser/binding-linux-s390x-gnu@0.155.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.156.0':
'@oxc-parser/binding-linux-x64-gnu@0.155.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.156.0':
'@oxc-parser/binding-linux-x64-musl@0.155.0':
optional: true
'@oxc-parser/binding-openharmony-arm64@0.156.0':
'@oxc-parser/binding-openharmony-arm64@0.155.0':
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.156.0':
'@oxc-parser/binding-win32-arm64-msvc@0.155.0':
optional: true
'@oxc-parser/binding-win32-ia32-msvc@0.156.0':
'@oxc-parser/binding-win32-ia32-msvc@0.155.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.156.0':
'@oxc-parser/binding-win32-x64-msvc@0.155.0':
optional: true
'@oxc-project/types@0.155.0': {}
'@oxc-project/types@0.156.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
@@ -8685,12 +8690,12 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
knip@6.44.2:
knip@6.44.1:
dependencies:
formatly: 0.11.4
get-tsconfig: 4.14.3
jiti: 2.7.0
oxc-parser: 0.156.0
oxc-parser: 0.155.0
oxc-resolver: 11.24.2
picomatch: 4.0.7
smol-toml: 1.9.1
@@ -9171,29 +9176,29 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
oxc-parser@0.156.0:
oxc-parser@0.155.0:
dependencies:
'@oxc-project/types': 0.156.0
'@oxc-project/types': 0.155.0
optionalDependencies:
'@oxc-parser/binding-android-arm-eabi': 0.156.0
'@oxc-parser/binding-android-arm64': 0.156.0
'@oxc-parser/binding-darwin-arm64': 0.156.0
'@oxc-parser/binding-darwin-x64': 0.156.0
'@oxc-parser/binding-freebsd-x64': 0.156.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.156.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.156.0
'@oxc-parser/binding-linux-arm64-gnu': 0.156.0
'@oxc-parser/binding-linux-arm64-musl': 0.156.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.156.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.156.0
'@oxc-parser/binding-linux-riscv64-musl': 0.156.0
'@oxc-parser/binding-linux-s390x-gnu': 0.156.0
'@oxc-parser/binding-linux-x64-gnu': 0.156.0
'@oxc-parser/binding-linux-x64-musl': 0.156.0
'@oxc-parser/binding-openharmony-arm64': 0.156.0
'@oxc-parser/binding-win32-arm64-msvc': 0.156.0
'@oxc-parser/binding-win32-ia32-msvc': 0.156.0
'@oxc-parser/binding-win32-x64-msvc': 0.156.0
'@oxc-parser/binding-android-arm-eabi': 0.155.0
'@oxc-parser/binding-android-arm64': 0.155.0
'@oxc-parser/binding-darwin-arm64': 0.155.0
'@oxc-parser/binding-darwin-x64': 0.155.0
'@oxc-parser/binding-freebsd-x64': 0.155.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.155.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.155.0
'@oxc-parser/binding-linux-arm64-gnu': 0.155.0
'@oxc-parser/binding-linux-arm64-musl': 0.155.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.155.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.155.0
'@oxc-parser/binding-linux-riscv64-musl': 0.155.0
'@oxc-parser/binding-linux-s390x-gnu': 0.155.0
'@oxc-parser/binding-linux-x64-gnu': 0.155.0
'@oxc-parser/binding-linux-x64-musl': 0.155.0
'@oxc-parser/binding-openharmony-arm64': 0.155.0
'@oxc-parser/binding-win32-arm64-msvc': 0.155.0
'@oxc-parser/binding-win32-ia32-msvc': 0.155.0
'@oxc-parser/binding-win32-x64-msvc': 0.155.0
oxc-resolver@11.24.2:
optionalDependencies:
@@ -10002,7 +10007,7 @@ snapshots:
deepmerge-ts: 8.0.2
https-proxy-agent: 9.1.0(supports-color@7.2.0)
undici: 8.12.3
ws: 8.22.2
ws: 8.22.1
transitivePeerDependencies:
- bufferutil
- kerberos
@@ -10070,7 +10075,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.22.2: {}
ws@8.22.1: {}
y18n@5.0.8: {}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'
const reviewer = 'renovate-minor-review.lock.yml'
const agents = ['Copilot', 'copilot-swe-agent', 'copilot-swe-agent[bot]']
const agents = ['copilot-swe-agent', 'copilot-swe-agent[bot]']
const checks = {
'.github/workflows/frontend-check.yml': [
'Run Prettier check',
+5 -1
View File
@@ -552,7 +552,11 @@ const SERVICE_BINARIES = [
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
// The Linux uninstaller is packaged through `files`, whose sources cannot vary by target.
const suffix =
platform === 'linux' && name !== 'clash-verge-service-uninstall'
? `-${SIDECAR_HOST}`
: ''
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
+5 -5
View File
@@ -58,13 +58,13 @@ smartstring = { workspace = true, features = ["serde"] }
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.4.5"
rfd = { version = "0.18.0", default-features = false, features = ["common-controls-v6", "xdg-portal"] }
rfd = { version = "0.17.2", default-features = false, features = ["common-controls-v6"] }
dirs = "7.0.0"
dunce = "1.0.5"
nanoid = "0.5"
chrono = { workspace = true }
boa_engine = "0.22.0"
http-body-util = "0.1.5"
http-body-util = "0.1.4"
percent-encoding = "2.3.2"
reqwest = { workspace = true }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [
@@ -77,8 +77,8 @@ tauri-plugin-dialog = "2.8.4"
tauri-plugin-fs = "2.7.1"
tauri-plugin-deep-link = "2.6.3"
tauri-plugin-window-state = "2.5.0"
zip = "9.4.2"
flate2 = "1.1.11"
zip = "9.3.0"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
@@ -97,7 +97,7 @@ async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client",
] }
arc-swap = "1.9.4"
arc-swap = "1.9.3"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.11"
@@ -5,6 +5,97 @@ chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
SERVICE_UNIT="clash-verge-service.service"
SERVICE_UNIT_FILE="/etc/systemd/system/$SERVICE_UNIT"
# Read by the app, which offers a reinstall when the package could not keep the Service.
UPGRADE_MARKER="/run/clash-verge-service-upgrade"
# Read by /usr/bin/clash-verge-service-uninstall to keep the Service through the previous %preun.
TRANSACTION_RECORD="/run/clash-verge-service-rpm-upgrade"
# Set to the previous state when restoring it fails; the app shows it even if the Service is ready.
UNRESTORED_STATE=""
write_upgrade_marker() {
(
umask 022
{
printf 'id=%s\n' "$(date +%s%N)"
if [ -n "$UNRESTORED_STATE" ]; then
printf 'restore=%s\n' "$UNRESTORED_STATE"
fi
} >"$UPGRADE_MARKER"
)
}
# Scriptlets run as children of the process running the rpm transaction; its start time
# keeps a stale record from matching a later process that reuses the PID.
record_transaction() {
local start
start="$(sed 's/.*) //' "/proc/$PPID/stat" | cut -d' ' -f20)"
[ -n "$start" ] || return 1
(umask 022 && printf '%s %s\n' "$PPID" "$start" >"$TRANSACTION_RECORD")
}
existing_service_gid() {
local group
group="$(sed -n 's/^Group=//p' "$SERVICE_UNIT_FILE" | head -n 1)"
if [ -n "$group" ]; then
getent group "$group" | cut -d: -f3
fi
return 0
}
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the package unconfigured or make the app report a failed update.
# The installer always enables and starts the unit, so the previous enablement and activity are restored.
reinstall_existing_service() {
local gid="$1" enabled active=no
enabled="$(systemctl is-enabled "$SERVICE_UNIT" 2>/dev/null)"
if systemctl is-active --quiet "$SERVICE_UNIT"; then
active=yes
fi
local status=0 restored=yes
if [ "$enabled" = "masked-runtime" ]; then
systemctl unmask --runtime --quiet "$SERVICE_UNIT" || restored=no
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; Clash Verge will offer to reinstall it." >&2
status=1
fi
case "$enabled" in
disabled) systemctl disable --quiet "$SERVICE_UNIT" || restored=no ;;
enabled-runtime) { systemctl disable --quiet "$SERVICE_UNIT" && systemctl enable --runtime --quiet "$SERVICE_UNIT"; } || restored=no ;;
masked-runtime) systemctl mask --runtime --quiet "$SERVICE_UNIT" || restored=no ;;
esac
if [ "$active" = "no" ]; then
systemctl stop "$SERVICE_UNIT" || restored=no
fi
if [ "$restored" = "no" ]; then
UNRESTORED_STATE="${enabled:-unknown},$([ "$active" = yes ] && echo active || echo inactive)"
echo "Warning: restoring the previous Clash Verge Service state ($UNRESTORED_STATE) failed; check it with systemctl." >&2
status=1
fi
return "$status"
}
# Upgrades keep an existing Service current; first installs and explicit removals never add one.
# A symlinked unit is masked or managed by the administrator and is left alone.
# The previous %preun runs after this script; the record keeps it from removing the Service.
# Without a record that %preun removes the Service, so the marker makes the app offer a reinstall.
if [ "$1" -ge 2 ] && [ -d /run/systemd/system ] && [ -f "$SERVICE_UNIT_FILE" ] && [ ! -L "$SERVICE_UNIT_FILE" ]; then
recorded=yes
record_transaction || recorded=no
gid="$(existing_service_gid)"
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group could not be resolved; Clash Verge will offer to reinstall the Service." >&2
write_upgrade_marker
elif reinstall_existing_service "$gid" && [ "$recorded" = yes ]; then
rm -f "$UPGRADE_MARKER"
else
write_upgrade_marker
fi
fi
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
+77
View File
@@ -3,6 +3,83 @@ chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
SERVICE_UNIT="clash-verge-service.service"
SERVICE_UNIT_FILE="/etc/systemd/system/$SERVICE_UNIT"
# Read by the app, which offers a reinstall when the package could not keep the Service.
UPGRADE_MARKER="/run/clash-verge-service-upgrade"
# Set to the previous state when restoring it fails; the app shows it even if the Service is ready.
UNRESTORED_STATE=""
write_upgrade_marker() {
(
umask 022
{
printf 'id=%s\n' "$(date +%s%N)"
if [ -n "$UNRESTORED_STATE" ]; then
printf 'restore=%s\n' "$UNRESTORED_STATE"
fi
} >"$UPGRADE_MARKER"
)
}
existing_service_gid() {
local group
group="$(sed -n 's/^Group=//p' "$SERVICE_UNIT_FILE" | head -n 1)"
if [ -n "$group" ]; then
getent group "$group" | cut -d: -f3
fi
return 0
}
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the package unconfigured or make the app report a failed update.
# The installer always enables and starts the unit, so the previous enablement and activity are restored.
reinstall_existing_service() {
local gid="$1" enabled active=no
enabled="$(systemctl is-enabled "$SERVICE_UNIT" 2>/dev/null)"
if systemctl is-active --quiet "$SERVICE_UNIT"; then
active=yes
fi
local status=0 restored=yes
if [ "$enabled" = "masked-runtime" ]; then
systemctl unmask --runtime --quiet "$SERVICE_UNIT" || restored=no
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; Clash Verge will offer to reinstall it." >&2
status=1
fi
case "$enabled" in
disabled) systemctl disable --quiet "$SERVICE_UNIT" || restored=no ;;
enabled-runtime) { systemctl disable --quiet "$SERVICE_UNIT" && systemctl enable --runtime --quiet "$SERVICE_UNIT"; } || restored=no ;;
masked-runtime) systemctl mask --runtime --quiet "$SERVICE_UNIT" || restored=no ;;
esac
if [ "$active" = "no" ]; then
systemctl stop "$SERVICE_UNIT" || restored=no
fi
if [ "$restored" = "no" ]; then
UNRESTORED_STATE="${enabled:-unknown},$([ "$active" = yes ] && echo active || echo inactive)"
echo "Warning: restoring the previous Clash Verge Service state ($UNRESTORED_STATE) failed; check it with systemctl." >&2
status=1
fi
return "$status"
}
# Upgrades keep an existing Service current; first installs and explicit removals never add one.
# A symlinked unit is masked or managed by the administrator and is left alone.
if [ "$1" = "configure" ] && [ -n "$2" ] && [ -d /run/systemd/system ] &&
[ -f "$SERVICE_UNIT_FILE" ] && [ ! -L "$SERVICE_UNIT_FILE" ]; then
gid="$(existing_service_gid)"
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group could not be resolved; Clash Verge will offer to reinstall the Service." >&2
write_upgrade_marker
elif reinstall_existing_service "$gid"; then
rm -f "$UPGRADE_MARKER"
else
write_upgrade_marker
fi
fi
. /etc/os-release
if [ "$ID" = "deepin" ]; then
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Installed as /usr/bin/clash-verge-service-uninstall. During an rpm upgrade the new %post
# reinstalls an existing Service before the previous package's %preun runs, and released
# %preun scripts uninstall it unconditionally through this path. Skip only that call: one
# made by a scriptlet of the transaction that %post recorded.
RECORD=/run/clash-verge-service-rpm-upgrade
UNINSTALLER=/usr/bin/clash-verge-service-uninstall.real
# Fields after the parenthesised command name, which may itself contain spaces.
stat_field() {
sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f"$2"
}
if [ -f "$RECORD" ] && [ ! -L "$RECORD" ] && read -r txn_pid txn_start <"$RECORD" && [ -n "$txn_start" ]; then
pid=$PPID
depth=0
while [ "$depth" -lt 4 ] && [ -n "$pid" ] && [ "$pid" -gt 1 ]; do
if [ "$pid" = "$txn_pid" ] && [ "$(stat_field "$pid" 20)" = "$txn_start" ]; then
rm -f "$RECORD"
echo "Keeping the Clash Verge Service through this upgrade."
exit 0
fi
pid="$(stat_field "$pid" 2)"
depth=$((depth + 1))
done
fi
exec "$UNINSTALLER" "$@"
+57 -3
View File
@@ -5,10 +5,16 @@ use crate::{
core::{
handle::Handle,
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::collections::{BTreeMap, HashSet};
use std::{
collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
@@ -21,12 +27,15 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
#[tauri::command]
#[specta::specta]
pub async fn switch_proxy_node(group_name: String, proxy_name: String, unfix: bool) -> CmdResult<()> {
crate::feat::switch_proxy_node(&group_name, &proxy_name, unfix)
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
@@ -115,3 +124,48 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
providers: providers.ok(),
}))
}
#[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
AsyncHandler::spawn(move || async move {
run_tray_sync_loop().await;
});
} else {
TRAY_SYNC_PENDING.store(true, Ordering::Release);
}
Ok(())
}
async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop {
match Tray::global().update_menu().await {
Ok(_) => {
logging!(debug, Type::Cmd, "Tray proxy selection synced successfully");
}
Err(e) => {
logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e:#}");
}
}
if !TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel) {
TRAY_SYNC_RUNNING.store(false, Ordering::Release);
if TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel)
&& TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
continue;
}
break;
}
}
}
+7
View File
@@ -141,6 +141,13 @@ pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
#[specta::specta]
#[cfg_attr(not(target_os = "linux"), allow(clippy::missing_const_for_fn))]
pub fn take_service_upgrade_state_notice() -> Option<String> {
crate::core::service::take_service_upgrade_state_notice()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> {
+14
View File
@@ -750,6 +750,20 @@ impl CoreManager {
self.controlled_stop_core_inner().await
}
/// Holds the core lifecycle for a package install, stopping a Service-run core first and returning
/// whether it did, so no core start or restart overlaps the package scripts reinstalling the Service.
#[cfg(target_os = "linux")]
pub(crate) async fn pause_for_package_install(
&'static self,
) -> Result<(tokio::sync::MutexGuard<'static, ()>, bool)> {
let life = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Service) {
return Ok((life, false));
}
self.controlled_stop_core_inner().await?;
Ok((life, true))
}
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
+4 -1
View File
@@ -22,8 +22,11 @@ pub enum NoticeStatus {
#[serde(rename = "service_core::app_data_not_owned")]
ServiceCoreAppDataNotOwned,
#[serde(rename = "service_core::sidecar_fallback")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
ServiceCoreSidecarFallback,
#[serde(rename = "service_core::upgrade_state_not_restored")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
ServiceCoreUpgradeStateNotRestored,
#[serde(rename = "dns_override::auto_disabled")]
DnsOverrideAutoDisabled,
#[serde(rename = "enhance::discarded_keys")]
+161 -1
View File
@@ -42,6 +42,8 @@ static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(
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);
#[cfg(target_os = "linux")]
static PENDING_SERVICE_UPGRADE: Mutex<Option<PendingServiceUpgrade>> = Mutex::new(None);
/// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
@@ -50,6 +52,7 @@ pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
NotAutoStarted,
UpgradeIncomplete,
}
/// The release installer registers AutoStart.
@@ -66,9 +69,118 @@ pub(crate) fn notify_service_fallback(reason: &str) {
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
/// Written by the Linux packages when an upgrade found the Service and may not have kept it.
#[cfg(target_os = "linux")]
const SERVICE_UPGRADE_MARKER: &str = "/run/clash-verge-service-upgrade";
const SERVICE_UPGRADE_INCOMPLETE: &str = "the package upgrade did not keep the Service ready";
/// Offers a reinstall once per package upgrade whose Service is not ready, since package script output never reaches the app.
#[cfg(target_os = "linux")]
pub(crate) async fn notify_incomplete_service_upgrade() {
let Ok(marker) = std::fs::read_to_string(SERVICE_UPGRADE_MARKER) else {
return;
};
let Some(id) = marker.lines().find_map(|line| line.strip_prefix("id=")) else {
return;
};
let ack = match dirs::app_home_dir() {
Ok(dir) => dir.join("service-upgrade-ack"),
Err(error) => {
logging!(warn, Type::Service, "无法定位服务升级确认文件: {error:#}");
return;
}
};
if std::fs::read_to_string(&ack).is_ok_and(|seen| seen == id) {
return;
}
let status = SERVICE_MANAGER.current().await;
let notice = service_upgrade_notice(&marker, matches!(status, ServiceStatus::Ready));
if !notice.reinstall && notice.unrestored_state.is_none() {
acknowledge_service_upgrade(&ack, id);
return;
}
// Without a window (silent start) the events are dropped; the frontend replays them on subscription.
*PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade {
ack,
id: id.to_owned(),
reinstall: notice.reinstall,
unrestored_state: notice.unrestored_state.map(str::to_owned),
});
if notice.reinstall {
logging!(warn, Type::Service, "安装包升级后服务未就绪: {status:?}");
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(SERVICE_UPGRADE_INCOMPLETE.to_owned());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
if let Some(state) = notice.unrestored_state {
logging!(warn, Type::Service, "安装包升级后未能恢复服务原状态: {state}");
Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, "");
}
}
#[cfg(target_os = "linux")]
fn acknowledge_service_upgrade(ack: &Path, id: &str) {
if let Err(error) = std::fs::write(ack, id) {
logging!(warn, Type::Service, "无法记录服务升级确认: {error}");
}
}
/// An upgrade whose notices are acknowledged on disk only after the frontend has taken all of them.
#[cfg(target_os = "linux")]
struct PendingServiceUpgrade {
ack: PathBuf,
id: String,
reinstall: bool,
unrestored_state: Option<String>,
}
#[cfg(target_os = "linux")]
fn settle_service_upgrade<T>(take: impl FnOnce(&mut PendingServiceUpgrade) -> T) -> Option<T> {
let (taken, settled) = {
let mut pending = PENDING_SERVICE_UPGRADE.lock();
let upgrade = pending.as_mut()?;
let taken = take(upgrade);
let settled = !upgrade.reinstall && upgrade.unrestored_state.is_none();
(taken, if settled { pending.take() } else { None })
};
if let Some(upgrade) = settled {
acknowledge_service_upgrade(&upgrade.ack, &upgrade.id);
}
Some(taken)
}
#[cfg(target_os = "linux")]
pub(crate) fn take_service_upgrade_state_notice() -> Option<String> {
settle_service_upgrade(|upgrade| upgrade.unrestored_state.take()).flatten()
}
#[cfg(not(target_os = "linux"))]
pub(crate) const fn take_service_upgrade_state_notice() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
#[derive(Debug, PartialEq, Eq)]
struct ServiceUpgradeNotice<'a> {
reinstall: bool,
/// Reported regardless of readiness: a reinstall enables and starts the Service, so it cannot restore this state.
unrestored_state: Option<&'a str>,
}
#[cfg(target_os = "linux")]
fn service_upgrade_notice(marker: &str, ready: bool) -> ServiceUpgradeNotice<'_> {
ServiceUpgradeNotice {
reinstall: !ready,
unrestored_state: marker.lines().find_map(|line| line.strip_prefix("restore=")),
}
}
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) {
Some(if reason == SERVICE_UPGRADE_INCOMPLETE {
#[cfg(target_os = "linux")]
settle_service_upgrade(|upgrade| upgrade.reinstall = false);
ServiceFallbackNotice::UpgradeIncomplete
} else if reason.starts_with(CORE_REJECTED_PREFIX) {
ServiceFallbackNotice::CoreRejected(reason)
} else if is_not_auto_started(&reason) {
ServiceFallbackNotice::NotAutoStarted
@@ -2124,6 +2236,11 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
#[cfg(target_os = "linux")]
use super::{
PENDING_SERVICE_UPGRADE, PendingServiceUpgrade, ServiceUpgradeNotice, service_upgrade_notice,
settle_service_upgrade, take_service_upgrade_state_notice,
};
use super::{
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
@@ -2786,4 +2903,47 @@ mod tests {
assert!(!store.state().sidecar_allowed);
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
#[cfg(target_os = "linux")]
#[test]
fn upgrade_marker_reports_unrestored_state_regardless_of_readiness() {
let restored = "id=1\n";
let unrestored = "id=1\nrestore=disabled,active\n";
let notice = |reinstall, unrestored_state| ServiceUpgradeNotice {
reinstall,
unrestored_state,
};
assert_eq!(service_upgrade_notice(restored, true), notice(false, None));
assert_eq!(service_upgrade_notice(restored, false), notice(true, None));
assert_eq!(
service_upgrade_notice(unrestored, true),
notice(false, Some("disabled,active"))
);
assert_eq!(
service_upgrade_notice(unrestored, false),
notice(true, Some("disabled,active"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn upgrade_marker_is_acknowledged_only_after_every_notice_is_taken() {
let ack = std::env::temp_dir().join(format!("verge-service-upgrade-ack-{}", std::process::id()));
let _ = std::fs::remove_file(&ack);
*PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade {
ack: ack.clone(),
id: "7".to_owned(),
reinstall: true,
unrestored_state: Some("disabled,active".to_owned()),
});
assert_eq!(take_service_upgrade_state_notice().as_deref(), Some("disabled,active"));
assert!(!ack.exists());
assert_eq!(take_service_upgrade_state_notice(), None);
settle_service_upgrade(|upgrade| upgrade.reinstall = false);
assert_eq!(std::fs::read_to_string(&ack).ok().as_deref(), Some("7"));
assert!(PENDING_SERVICE_UPGRADE.lock().is_none());
let _ = std::fs::remove_file(&ack);
}
}
+1 -3
View File
@@ -1000,9 +1000,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
Some((g, p)) => (g, p),
None => return,
};
if let Err(err) = feat::switch_proxy_node(group_name, proxy_name, false).await {
logging!(error, Type::Tray, "Failed to switch proxy: {err:#}");
}
feat::switch_proxy_node(group_name, proxy_name).await;
}
_ => {
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
+57 -8
View File
@@ -292,8 +292,61 @@ impl SilentUpdater {
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
async fn install(update: Update, bytes: Vec<u8>, timeout: Option<Duration>) -> Result<()> {
#[cfg(target_os = "linux")]
let mut install = Self::install_with_core_paused(update, bytes).await?;
#[cfg(not(target_os = "linux"))]
let mut install = tokio::task::spawn_blocking(move || update.install(&bytes));
let joined = match timeout {
Some(limit) => match tokio::time::timeout(limit, &mut install).await {
Ok(joined) => Ok(joined),
Err(_) => {
// The caller resumes the old app; once the package manager succeeds, restart on the new version.
#[cfg(target_os = "linux")]
tokio::spawn(async move {
if matches!(install.await, Ok(Ok(()))) {
crate::feat::restart_app().await;
}
});
Err(anyhow::anyhow!("install timed out ({}s)", limit.as_secs()))
}
},
None => Ok(install.await),
};
joined.and_then(|joined| {
joined??;
Ok(())
})
}
/// The package scripts reinstall an existing Service, killing the core it runs and its session, so the
/// core stays paused until the package manager exits, even after the caller stops waiting.
#[cfg(target_os = "linux")]
async fn install_with_core_paused(update: Update, bytes: Vec<u8>) -> Result<tokio::task::JoinHandle<Result<()>>> {
let manager = crate::core::CoreManager::global();
let (lifecycle, stopped_service_core) = manager
.pause_for_package_install()
.await
.context("failed to stop the Service core before installing the update")?;
let install = tokio::task::spawn_blocking(move || update.install(&bytes));
Ok(tokio::spawn(async move {
let result = match install.await {
Ok(result) => result.map_err(anyhow::Error::from),
Err(error) => Err(error.into()),
};
drop(lifecycle);
if result.is_err()
&& stopped_service_core
&& let Err(error) = manager.start_core().await
{
logging!(
error,
Type::System,
"Failed to restart the core after the update install failed: {error:#}"
);
}
result
}))
}
/// Installs a newer cached update before normal startup, if the user confirms.
@@ -369,11 +422,7 @@ impl SilentUpdater {
Self::show_update_splash(app_handle, &version);
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
match Self::install(update, bytes, Some(Duration::from_secs(30))).await {
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
@@ -417,7 +466,7 @@ impl SilentUpdater {
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::install(update, bytes, None).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
}
+24 -73
View File
@@ -4,7 +4,6 @@ use crate::{
cmd,
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
core::{CoreManager, handle, tray, validate::ValidationOutcome},
process::AsyncHandler,
utils::help::{mask_err, mask_url},
};
use anyhow::{Result, bail};
@@ -22,85 +21,37 @@ pub async fn toggle_proxy_profile(profile_index: String) {
}
}
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str, unfix: bool) -> Result<()> {
let mihomo = handle::Handle::mihomo();
let previous_proxy = if Config::verge().await.data_arc().auto_close_connection.unwrap_or(false) {
match mihomo.get_proxy_by_name(group_name).await {
Ok(group) => group.now.filter(|name| !name.is_empty()),
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read previous proxy selection for {group_name}: {err}"
);
None
}
}
} else {
None
};
if unfix {
mihomo.unfixed_proxy(group_name).await?;
} else {
mihomo.select_node_for_group(group_name, proxy_name).await?;
}
let save_result = if unfix {
crate::config::profiles::forget_selected_node(group_name).await
} else {
crate::config::profiles::record_selected_node(group_name, proxy_name).await
};
// The core selection is already applied; persistence failure must not report it as failed.
if let Err(error) = save_result {
/// Tell the profile which node this group is on now.
///
/// The core is already switched by the time this runs, so failing to record is not a reason to
/// report the switch as failed — but it is worth a log line, because what the profile holds is
/// what gets re-applied the next time a core starts.
async fn record_switched_node(group_name: &str, proxy_name: &str) {
if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await {
logging!(
warn,
Type::Config,
"Failed to persist proxy selection for {group_name}: {error:#}"
"切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}",
group_name,
proxy_name
);
}
announce(Refresh::Proxies);
}
if let Some(previous_proxy) = previous_proxy {
AsyncHandler::spawn(move || async move {
let connections = match mihomo.get_connections().await {
Ok(connections) => connections,
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read connections after switching proxy: {err}"
);
return;
}
};
futures::future::join_all(
connections
.connections
.unwrap_or_default()
.into_iter()
.filter(|connection| connection.chains.contains(&previous_proxy))
.map(|connection| async move {
if let Err(err) = mihomo.close_connection(&connection.id).await {
logging!(
warn,
Type::Core,
"Failed to close connection {} after switching proxy: {err}",
connection.id
);
}
}),
)
.await;
});
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}");
}
}
if let Err(err) = tray::Tray::global().update_menu().await {
logging!(
warn,
Type::Tray,
"Failed to refresh tray after switching proxy: {err:#}"
);
}
Ok(())
}
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
+3 -1
View File
@@ -45,8 +45,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::switch_proxy_node,
cmd::forget_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::set_dns_override,
@@ -55,6 +56,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_service_upgrade_state_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
+2
View File
@@ -78,6 +78,8 @@ async fn resolve_setup() {
let core_init = AsyncHandler::spawn(|| async {
init_core_manager().await;
#[cfg(target_os = "linux")]
crate::core::service::notify_incomplete_service_upgrade().await;
});
let _ = futures::join!(
+10 -3
View File
@@ -11,7 +11,11 @@
"conflicts": ["clash-verge"],
"replaces": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
"preRemoveScript": "./packages/linux/pre-remove.sh",
"files": {
"/usr/bin/clash-verge-service-uninstall": "./packages/linux/service-uninstall.sh",
"/usr/bin/clash-verge-service-uninstall.real": "./sidecar/clash-verge-service-uninstall"
}
},
"rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"],
@@ -21,13 +25,16 @@
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
"preRemoveScript": "./packages/linux/pre-remove.sh",
"files": {
"/usr/bin/clash-verge-service-uninstall": "./packages/linux/service-uninstall.sh",
"/usr/bin/clash-verge-service-uninstall.real": "./sidecar/clash-verge-service-uninstall"
}
}
},
"externalBin": [
"./sidecar/clash-verge-service",
"./sidecar/clash-verge-service-install",
"./sidecar/clash-verge-service-uninstall",
"./sidecar/verge-mihomo",
"./sidecar/verge-mihomo-alpha"
]
+5 -1
View File
@@ -606,6 +606,9 @@ export const CurrentProxyCard = () => {
)
const { select } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
onError: (error) => {
console.error('代理切换失败', error)
refreshProxy()
@@ -752,9 +755,10 @@ export const CurrentProxyCard = () => {
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
return
}
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, selectedGroup.fixed!)
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
},
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
)
@@ -28,7 +28,9 @@ import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
const remedyFor = (reason: ServiceRequestReason): Remedy =>
reason === 'serviceLocationRefused' || reason === 'serviceNotAutoStarted'
reason === 'serviceLocationRefused' ||
reason === 'serviceNotAutoStarted' ||
reason === 'serviceUpgradeIncomplete'
? 'reinstallAndRestart'
: reason === 'sysproxySidecarReady'
? 'restartOnly'
@@ -43,6 +45,8 @@ const EXPLANATION = {
'layout.components.serviceMigration.locationRefusedMessage',
serviceNotAutoStarted:
'layout.components.serviceMigration.notAutoStartedMessage',
serviceUpgradeIncomplete:
'layout.components.serviceMigration.upgradeIncompleteMessage',
} as const
const TITLE = {
@@ -51,6 +55,7 @@ const TITLE = {
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
serviceLocationRefused: 'layout.components.serviceMigration.repair',
serviceNotAutoStarted: 'layout.components.serviceMigration.repair',
serviceUpgradeIncomplete: 'layout.components.serviceMigration.reinstall',
} as const
const stateToRestore = (
+5 -2
View File
@@ -470,6 +470,9 @@ function NormalProxyGroups(props: { mode: string }) {
}, [handleScroll])
const { select } = useProxySelection({
onSuccess: () => {
onProxies()
},
onError: (error) => {
console.error('代理切换失败', error)
onProxies()
@@ -484,12 +487,12 @@ function NormalProxyGroups(props: { mode: string }) {
if (group.name === globalGroupName) {
pendingRevealRef.current =
member.kind === 'group' ? member.ref.name : null
// The backend selection event refreshes the view.
// The selection refetches the view, so skip onHeadState's refetch.
if (member.kind === 'group') {
setHeadState(member.ref.name, { open: true })
}
}
select(group.name, member.ref.name, group.fixed!)
select(group.name, member.ref.name, group.now!, group.fixed!)
},
[globalGroupName, select, setHeadState],
)
+1 -7
View File
@@ -209,13 +209,7 @@ export const useRenderList = (
const renderGroups = useMemo(() => {
if (!proxyView) return []
// A profile whose only group is GLOBAL (mihomo then skips its built-in one)
// or that has no proxy-groups has nothing else to show in rule mode.
if (mode === 'rule') {
return proxyView.groups.length || !proxyView.global
? proxyView.groups
: [proxyView.global]
}
if (mode === 'rule') return proxyView.groups
const { global } = proxyView
if (!global) return []
const current = findCurrentGroupMember(proxyView, global)?.member
+29 -13
View File
@@ -1,35 +1,51 @@
import { selectNodeForGroup } from 'tauri-plugin-mihomo-api'
import { expect, it, vi } from 'vitest'
import { switchProxyNode } from '@/services/cmds'
import { useProxySelection } from './use-proxy-selection'
const record = vi.hoisted(() => vi.fn())
vi.mock('react', () => ({
useCallback: (callback: unknown) => callback,
useRef: (current: unknown) => ({ current }),
}))
vi.mock('@/services/cmds', () => ({ switchProxyNode: vi.fn() }))
vi.mock('@/hooks/use-record-selection', () => ({
useRecordSelection: () => record,
useForgetSelection: () => vi.fn(),
}))
vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) }))
vi.mock('tauri-plugin-mihomo-api', () => ({
selectNodeForGroup: vi.fn(),
unfixedProxy: vi.fn(),
}))
vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
it('requests independent group selections while another group is pending', async () => {
it('applies and records independent group selections while another group is pending', async () => {
let finishFirst!: () => void
vi.mocked(switchProxyNode)
vi.mocked(selectNodeForGroup)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFirst = () => resolve(null)
finishFirst = resolve
}),
)
.mockResolvedValueOnce(null)
const first = useProxySelection()
const second = useProxySelection()
.mockResolvedValueOnce(undefined)
const firstSuccess = vi.fn()
const secondSuccess = vi.fn()
const first = useProxySelection({ onSuccess: firstSuccess })
const second = useProxySelection({ onSuccess: secondSuccess })
first.select('Group A', 'Node A')
second.select('Group B', 'Node B', 'Node B')
second.select('Group B', 'Node B')
await vi.waitFor(() =>
expect(switchProxyNode).toHaveBeenCalledWith('Group B', 'Node B', true),
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
)
expect(switchProxyNode).toHaveBeenCalledWith('Group A', 'Node A', false)
expect(switchProxyNode).toHaveBeenCalledTimes(2)
expect(selectNodeForGroup).toHaveBeenCalledTimes(2)
expect(firstSuccess).not.toHaveBeenCalled()
expect(secondSuccess).toHaveBeenCalledOnce()
finishFirst()
await vi.waitFor(() =>
expect(record).toHaveBeenCalledWith('Group A', 'Node A'),
)
expect(firstSuccess).toHaveBeenCalledOnce()
})
+96 -8
View File
@@ -1,44 +1,132 @@
import { useCallback } from 'react'
import {
closeConnection,
getConnections,
selectNodeForGroup,
unfixedProxy,
} from 'tauri-plugin-mihomo-api'
import { switchProxyNode } from '@/services/cmds'
import {
useForgetSelection,
useRecordSelection,
} from '@/hooks/use-record-selection'
import { useVerge } from '@/hooks/use-verge'
import { syncTrayProxySelection } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { debugLog } from '@/utils/debug'
// 缓存连接清理
const cleanupConnections = async (previousProxy: string) => {
try {
const { connections } = await getConnections()
const cleanupPromises = (connections ?? [])
.filter((conn) => conn.chains.includes(previousProxy))
.map((conn) => closeConnection(conn.id))
if (cleanupPromises.length > 0) {
await Promise.allSettled(cleanupPromises)
debugLog(`[ProxySelection] 清理了 ${cleanupPromises.length} 个连接`)
}
} catch (error) {
console.warn('[ProxySelection] 连接清理失败:', error)
}
}
interface ProxySelectionOptions {
onSuccess?: () => void
onError?: (error: any) => void
}
interface ProxyChangeRequest {
groupName: string
proxyName: string
previousProxy?: string
fixed?: string
}
// 代理选择 Hook
export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const { onError } = options
const recordSelection = useRecordSelection()
const forgetSelection = useForgetSelection()
const { verge } = useVerge()
const { onSuccess, onError } = options
const autoCloseConnection = verge?.auto_close_connection ?? false
// 切换节点
const syncTraySelection = useCallback(() => {
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxySelection] 托盘状态同步失败:', error)
})
}, [])
const executeChange = useCallback(
async ({ groupName, proxyName, fixed }: ProxyChangeRequest) => {
async (request: ProxyChangeRequest) => {
const { groupName, proxyName, previousProxy, fixed } = request
const isFixedProxy = fixed === proxyName
if (isFixedProxy) {
debugLog(`[ProxySelection] 代理取消固定: ${groupName} -> ${proxyName}`)
} else {
debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`)
}
try {
await mutate(
() => switchProxyNode(groupName, proxyName, fixed === proxyName),
const result = await mutate(
() =>
isFixedProxy
? unfixedProxy(groupName)
: selectNodeForGroup(groupName, proxyName),
{ id: `select-proxy-node:${groupName}`, errorNotice: false },
)
if (!result.ok) return
onSuccess?.()
syncTraySelection()
if (isFixedProxy) {
await forgetSelection(groupName)
debugLog(`[ProxySelection] 代理和状态同步完成: ${groupName}`)
} else {
await recordSelection(groupName, proxyName)
debugLog(
`[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`,
)
}
if (autoCloseConnection && previousProxy) {
void cleanupConnections(previousProxy)
}
} catch (error) {
console.error(
`[ProxySelection] Failed to switch proxy: ${groupName} -> ${proxyName}`,
`[ProxySelection] 代理切换失败: ${groupName} -> ${proxyName}`,
error,
)
onError?.(error)
}
},
[onError],
[
autoCloseConnection,
forgetSelection,
onError,
onSuccess,
recordSelection,
syncTraySelection,
],
)
const select = useCallback(
(groupName: string, proxyName: string, fixed?: string) => {
(
groupName: string,
proxyName: string,
previousProxy?: string,
fixed?: string,
) => {
void executeChange({
groupName,
proxyName,
previousProxy,
fixed,
})
},
+14 -1
View File
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { recordSelectedNode } from '@/services/cmds'
import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
/**
@@ -19,3 +19,16 @@ export const useRecordSelection = () => {
}
}, [])
}
export const useForgetSelection = () => {
return useCallback(async (groupName: string) => {
try {
await mutate(() => forgetSelectedNode(groupName), {
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
console.error('[Selection] 清除代理选择失败:', error)
}
}, [])
}
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "لا يمكن للخدمة تشغيل النواة لأن مسار التثبيت أو أذونات المجلد لم تجتز فحص الأمان. أعد تثبيت الخدمة لاستعادة ملفاتها وأذوناتها، ثم حاول مجددًا. قد يطلب Windows موافقة المسؤول.",
"notAutoStartedMessage": "تم تغيير نوع بدء تشغيل خدمة Clash Verge إلى «يدوي» أو «معطّل» (غالبًا بواسطة أدوات التحسين)، لذلك لم تعد تبدأ مع Windows؛ تستخدم هذه الجلسة Sidecar. أعد تثبيت الخدمة لاستعادة التشغيل التلقائي. قد يطلب Windows موافقة المسؤول.",
"upgradeIncompleteMessage": "تمت ترقية Clash Verge، لكن خدمة Clash Verge المثبتة سابقًا غير جاهزة. أعد تثبيت الخدمة لمواصلة استخدامها؛ قد يطلب النظام مصادقة المسؤول.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "تعذّر على الخدمة التأكد من أن ملفات النواة ومجلداتها آمنة للتشغيل بصلاحيات مرتفعة.",
"permissionRepairGuide": "عرض خطوات الإصلاح",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.",
"upgradeStateNotRestored": "بعد الترقية تعذّرت استعادة حالة بدء التشغيل والتشغيل السابقة لخدمة Clash Verge ({{state}}). لن تؤدي إعادة تثبيت الخدمة إلى استعادتها؛ تحقق منها باستخدام systemctl status clash-verge-service واضبطها باستخدام systemctl.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "Der Dienst kann den Kern nicht starten, da sein Installationspfad oder die Verzeichnisberechtigungen die Sicherheitsprüfung nicht bestanden haben. Installieren Sie den Dienst erneut, um Dateien und Berechtigungen wiederherzustellen, und versuchen Sie es erneut. Windows kann eine Administratorbestätigung anfordern.",
"notAutoStartedMessage": "Der Starttyp des Clash-Verge-Dienstes wurde auf „Manuell“ oder „Deaktiviert“ geändert (häufig durch Optimierungsprogramme), daher startet er nicht mehr mit Windows; diese Sitzung verwendet Sidecar. Installieren Sie den Dienst erneut, um den automatischen Start wiederherzustellen. Windows kann eine Administratorbestätigung anfordern.",
"upgradeIncompleteMessage": "Clash Verge wurde aktualisiert, aber der zuvor installierte Clash Verge-Dienst ist nicht bereit. Installieren Sie den Dienst neu, um ihn weiter zu verwenden; das System fragt möglicherweise nach einer Administrator-Authentifizierung.",
"title": "Dienst-Update erforderlich",
"message": "Die Version des Clash-Verge-Dienstes passt nicht zu der Version, die diese App benötigt. Wählen Sie „Dienst neu installieren“, um den vorhandenen Dienst zu deinstallieren und die benötigte Version zu installieren, oder fahren Sie für diese Sitzung mit Sidecar fort.",
"checkingMessage": "Der Status des Systemdienstes und der aktuelle Betriebsmodus werden geprüft. Bitte warten…",
+1
View File
@@ -697,6 +697,7 @@
"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.",
"upgradeStateNotRestored": "Nach dem Upgrade konnte der vorherige Autostart- und Laufzustand des Clash Verge-Dienstes ({{state}}) nicht wiederhergestellt werden. Eine Neuinstallation des Dienstes stellt ihn nicht wieder her; prüfen Sie ihn mit systemctl status clash-verge-service und passen Sie ihn mit systemctl an.",
"uninstallSuccess": "Service erfolgreich deinstalliert"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "The service cannot start the core because its installation path or directory permissions failed a security check. Reinstall the service to restore its files and permissions, then try again. Windows may ask for administrator approval.",
"notAutoStartedMessage": "The Clash Verge service startup type was changed to Manual or Disabled (often by system optimizer tools), so it no longer starts with Windows; this session is using Sidecar. Reinstall the service to restore automatic startup. Windows may ask for administrator approval.",
"upgradeIncompleteMessage": "Clash Verge was upgraded, but the Clash Verge service it had installed is not ready. Reinstall the service to keep using it; the system may ask for administrator authentication.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"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.",
"upgradeStateNotRestored": "After the upgrade, Clash Verge could not restore the service's previous startup and running state ({{state}}). Reinstalling the service will not restore it; check it with systemctl status clash-verge-service and adjust it with systemctl.",
"uninstallSuccess": "Service Uninstalled Successfully"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "El servicio no puede iniciar el núcleo porque su ruta de instalación o los permisos del directorio no superaron una comprobación de seguridad. Reinstale el servicio para restaurar sus archivos y permisos e inténtelo de nuevo. Windows puede solicitar autorización de administrador.",
"notAutoStartedMessage": "El tipo de inicio del servicio de Clash Verge se cambió a Manual o Deshabilitado (a menudo por herramientas de optimización), por lo que ya no se inicia con Windows; esta sesión usa Sidecar. Reinstale el servicio para restaurar el inicio automático. Windows puede solicitar autorización de administrador.",
"upgradeIncompleteMessage": "Clash Verge se actualizó, pero el servicio de Clash Verge que estaba instalado no está listo. Reinstala el servicio para seguir usándolo; el sistema puede solicitar autenticación de administrador.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"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.",
"upgradeStateNotRestored": "Tras la actualización no se pudo restaurar el estado anterior de inicio y ejecución del servicio de Clash Verge ({{state}}). Reinstalar el servicio no lo restaurará; compruébelo con systemctl status clash-verge-service y ajústelo con systemctl.",
"uninstallSuccess": "Servicio desinstalado con éxito"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "سرویس نمی‌تواند هسته را اجرا کند، زیرا مسیر نصب یا مجوزهای پوشه در بررسی امنیتی تأیید نشده‌اند. برای بازیابی فایل‌ها و مجوزها، سرویس را دوباره نصب و سپس مجدداً تلاش کنید. ممکن است ویندوز تأیید مدیر را درخواست کند.",
"notAutoStartedMessage": "نوع راه‌اندازی سرویس Clash Verge به «دستی» یا «غیرفعال» تغییر کرده است (اغلب توسط ابزارهای بهینه‌ساز)، بنابراین دیگر همراه ویندوز اجرا نمی‌شود؛ این نشست از Sidecar استفاده می‌کند. برای بازگرداندن راه‌اندازی خودکار، سرویس را دوباره نصب کنید. ممکن است ویندوز تأیید مدیر را درخواست کند.",
"upgradeIncompleteMessage": "Clash Verge ارتقا یافت، اما سرویس Clash Verge که قبلاً نصب شده بود آماده نیست. برای ادامه استفاده، سرویس را دوباره نصب کنید؛ ممکن است سیستم احراز هویت مدیر را درخواست کند.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "سرویس نتوانست ایمن بودن فایل‌ها و پوشه‌های هسته را برای اجرا با دسترسی بالا تأیید کند.",
"permissionRepairGuide": "مشاهدهٔ راهنمای رفع مشکل",
"sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه می‌یابد؛ سرویس همچنان در دسترس نیست.",
"upgradeStateNotRestored": "پس از ارتقا، وضعیت قبلی راه‌اندازی خودکار و اجرای سرویس Clash Verge ({{state}}) بازگردانده نشد. نصب دوباره سرویس آن را بازنمی‌گرداند؛ با systemctl status clash-verge-service آن را بررسی و با systemctl تنظیم کنید.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "Layanan tidak dapat memulai inti karena jalur instalasi atau izin direktori tidak lolos pemeriksaan keamanan. Pasang ulang layanan untuk memulihkan berkas dan izinnya, lalu coba lagi. Windows mungkin meminta persetujuan administrator.",
"notAutoStartedMessage": "Jenis startup layanan Clash Verge diubah menjadi Manual atau Nonaktif (sering oleh alat pengoptimal), sehingga tidak lagi berjalan saat Windows dimulai; sesi ini menggunakan Sidecar. Pasang ulang layanan untuk memulihkan startup otomatis. Windows mungkin meminta persetujuan administrator.",
"upgradeIncompleteMessage": "Clash Verge telah ditingkatkan, tetapi layanan Clash Verge yang sebelumnya terpasang belum siap. Pasang ulang layanan untuk terus menggunakannya; sistem mungkin meminta autentikasi administrator.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"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.",
"upgradeStateNotRestored": "Setelah pembaruan, status mulai otomatis dan berjalan sebelumnya dari layanan Clash Verge ({{state}}) tidak dapat dipulihkan. Memasang ulang layanan tidak akan memulihkannya; periksa dengan systemctl status clash-verge-service dan sesuaikan dengan systemctl.",
"uninstallSuccess": "Layanan Berhasil Dicopot"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "サービスのインストール先またはフォルダーのアクセス許可がセキュリティチェックに合格しなかったため、コアを起動できません。サービスを再インストールしてファイルとアクセス許可を復元し、再試行してください。Windows が管理者の承認を求める場合があります。",
"notAutoStartedMessage": "Clash Verge サービスのスタートアップの種類が「手動」または「無効」に変更されたため(最適化ツールによることが多い)、Windows の起動時に開始されません。このセッションは Sidecar で動作しています。サービスを再インストールすると自動起動に戻ります。Windows が管理者の承認を求める場合があります。",
"upgradeIncompleteMessage": "Clash Verge はアップグレードされましたが、インストール済みの Clash Verge サービスが準備できていません。引き続き使用するにはサービスを再インストールしてください。管理者認証を求められる場合があります。",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "コアファイルとフォルダーを高い権限で安全に実行できるか確認できませんでした。",
"permissionRepairGuide": "修復方法を見る",
"sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。",
"upgradeStateNotRestored": "アップグレード後、Clash Verge サービスの以前の自動起動と実行状態({{state}})を復元できませんでした。サービスを再インストールしても復元されません。systemctl status clash-verge-service で状態を確認し、systemctl で調整してください。",
"uninstallSuccess": "サービスのアンインストールに成功しました。"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "서비스 설치 경로나 폴더 권한이 보안 검사를 통과하지 못해 코어를 시작할 수 없습니다. 서비스를 다시 설치하여 파일과 권한을 복원한 후 다시 시도하세요. Windows에서 관리자 승인을 요청할 수 있습니다.",
"notAutoStartedMessage": "Clash Verge 서비스의 시작 유형이 수동 또는 사용 안 함으로 변경되어(최적화 도구가 변경하는 경우가 많음) Windows 시작 시 실행되지 않습니다. 이번 세션은 Sidecar로 실행 중입니다. 서비스를 다시 설치하면 자동 시작이 복원됩니다. Windows에서 관리자 승인을 요청할 수 있습니다.",
"upgradeIncompleteMessage": "Clash Verge가 업그레이드되었지만 설치되어 있던 Clash Verge 서비스가 준비되지 않았습니다. 계속 사용하려면 서비스를 다시 설치하세요. 관리자 인증을 요청할 수 있습니다.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "서비스가 코어 파일과 폴더를 높은 권한으로 안전하게 실행할 수 있는지 확인하지 못했습니다.",
"permissionRepairGuide": "해결 방법 보기",
"sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.",
"upgradeStateNotRestored": "업그레이드 후 Clash Verge 서비스의 이전 부팅 시 시작 및 실행 상태({{state}})를 복원하지 못했습니다. 서비스를 다시 설치해도 복원되지 않습니다. systemctl status clash-verge-service로 상태를 확인하고 systemctl로 조정하세요.",
"uninstallSuccess": "서비스가 제거되었습니다"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "O serviço não pode iniciar o núcleo porque o caminho de instalação ou as permissões do diretório falharam em uma verificação de segurança. Reinstale o serviço para restaurar seus arquivos e permissões e tente novamente. O Windows pode solicitar aprovação de administrador.",
"notAutoStartedMessage": "O tipo de inicialização do serviço Clash Verge foi alterado para Manual ou Desativado (geralmente por ferramentas de otimização do sistema), então ele não inicia mais com o Windows; esta sessão está usando Sidecar. Reinstale o serviço para restaurar a inicialização automática. O Windows pode solicitar aprovação de administrador.",
"upgradeIncompleteMessage": "O Clash Verge foi atualizado, mas o serviço do Clash Verge que estava instalado não está pronto. Reinstale o serviço para continuar a usá-lo; o sistema pode pedir autenticação de administrador.",
"title": "Atualização do serviço necessária",
"message": "A versão do serviço Clash Verge não corresponde à versão exigida por este app. Escolha Reinstalar serviço para desinstalar o serviço existente e instalar a versão exigida, ou continue com o Sidecar nesta sessão.",
"checkingMessage": "Verificando o status do serviço do sistema e o modo de execução atual. Aguarde…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "O serviço não conseguiu confirmar que os arquivos e pastas do núcleo são seguros para execução com privilégios elevados.",
"permissionRepairGuide": "Ver instruções de reparo",
"sidecarFallback": "O serviço não conseguiu iniciar o núcleo. Mudou para o modo Sidecar; o serviço continua indisponível.",
"upgradeStateNotRestored": "Após a atualização, não foi possível restaurar o estado anterior de inicialização e execução do serviço do Clash Verge ({{state}}). Reinstalar o serviço não o restaurará; verifique-o com systemctl status clash-verge-service e ajuste-o com systemctl.",
"uninstallSuccess": "Serviço Desinstalado com Sucesso"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "Служба не может запустить ядро: путь установки или права доступа к каталогу не прошли проверку безопасности. Переустановите службу, чтобы восстановить файлы и права доступа, затем повторите попытку. Windows может запросить подтверждение администратора.",
"notAutoStartedMessage": "Тип запуска службы Clash Verge изменён на «Вручную» или «Отключена» (часто это делают программы-оптимизаторы), поэтому она больше не запускается вместе с Windows; в этом сеансе используется Sidecar. Переустановите службу, чтобы вернуть автоматический запуск. Windows может запросить подтверждение администратора.",
"upgradeIncompleteMessage": "Clash Verge обновлён, но ранее установленная служба Clash Verge не готова. Переустановите службу, чтобы продолжить её использовать; система может запросить аутентификацию администратора.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "Служба не смогла подтвердить безопасность файлов и папок ядра для запуска с повышенными правами.",
"permissionRepairGuide": "Инструкция по исправлению",
"sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.",
"upgradeStateNotRestored": "После обновления не удалось восстановить прежнее состояние автозапуска и работы службы Clash Verge ({{state}}). Переустановка службы его не восстановит; проверьте её командой systemctl status clash-verge-service и настройте через systemctl.",
"uninstallSuccess": "Служба успешно удалена"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "Hizmetin kurulum yolu veya dizin izinleri güvenlik denetiminden geçemediği için çekirdek başlatılamıyor. Dosyaları ve izinleri geri yüklemek için hizmeti yeniden kurup tekrar deneyin. Windows yönetici onayı isteyebilir.",
"notAutoStartedMessage": "Clash Verge hizmetinin başlangıç türü El ile veya Devre dışı olarak değiştirildi (genellikle optimizasyon araçları tarafından), bu nedenle artık Windows ile başlamıyor; bu oturum Sidecar kullanıyor. Otomatik başlatmayı geri yüklemek için hizmeti yeniden kurun. Windows yönetici onayı isteyebilir.",
"upgradeIncompleteMessage": "Clash Verge yükseltildi, ancak yüklü olan Clash Verge hizmeti hazır değil. Kullanmaya devam etmek için hizmeti yeniden yükleyin; sistem yönetici kimlik doğrulaması isteyebilir.",
"title": "Hizmet güncellemesi gerekli",
"message": "Clash Verge hizmet sürümü, bu uygulamanın gerektirdiği sürümle eşleşmiyor. Mevcut hizmeti kaldırıp gerekli sürümü yüklemek için hizmeti yeniden kurun ya da bu oturumda Sidecar ile devam edin.",
"checkingMessage": "Sistem hizmetinin durumu ve geçerli çalışma modu denetleniyor. Lütfen bekleyin…",
+1
View File
@@ -697,6 +697,7 @@
"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.",
"upgradeStateNotRestored": "Yükseltmeden sonra Clash Verge hizmetinin önceki açılışta başlatma ve çalışma durumu ({{state}}) geri yüklenemedi. Hizmeti yeniden kurmak bunu geri yüklemez; systemctl status clash-verge-service ile kontrol edin ve systemctl ile ayarlayın.",
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "Хезмәтнең урнаштыру юлы яки каталог рөхсәтләре иминлек тикшерүен узмаганга, төшне эшләтеп булмый. Файлларны һәм рөхсәтләрне торгызу өчен хезмәтне яңадан урнаштырыгыз һәм кабатлап карагыз. Windows администратор раславын сорарга мөмкин.",
"notAutoStartedMessage": "Clash Verge хезмәтенең башлау төре «Кулдан» яки «Сүндерелгән» итеп үзгәртелгән (еш кына оптимизация программалары тарафыннан), шуңа күрә ул Windows белән бергә башланмый; бу сеанс Sidecar куллана. Автоматик башлауны кайтару өчен хезмәтне яңадан урнаштырыгыз. Windows администратор раславын сорарга мөмкин.",
"upgradeIncompleteMessage": "Clash Verge яңартылды, ләкин элек урнаштырылган Clash Verge хезмәте әзер түгел. Аны куллануны дәвам итәр өчен хезмәтне яңадан урнаштырыгыз; система администратор аутентификациясен сорарга мөмкин.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "Хезмәт төш файлларын һәм папкаларын югары хокуклар белән имин эшләтеп булачагын раслый алмады.",
"permissionRepairGuide": "Төзәтү күрсәтмәләрен карау",
"sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.",
"upgradeStateNotRestored": "Яңартудан соң Clash Verge хезмәтенең элекке автоҗибәрү һәм эшләү халәтен ({{state}}) кайтарып булмады. Хезмәтне яңадан урнаштыру аны кайтармый; аны systemctl status clash-verge-service белән тикшерегез һәм systemctl белән көйләгез.",
"uninstallSuccess": "Сервис уңышлы салдырылды"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "系统服务的安装路径或目录权限未通过安全检查,无法启动内核。请重新安装服务以恢复服务文件和权限,随后重试。Windows 可能会请求管理员授权。",
"notAutoStartedMessage": "Clash Verge 系统服务的启动类型已被改为手动或禁用(常见于优化软件的开机加速功能),开机后不会自动运行,本次已改用 Sidecar。请重新安装服务以恢复自动启动。Windows 可能会请求管理员授权。",
"upgradeIncompleteMessage": "Clash Verge 已升级,但原先安装的 Clash Verge 服务未就绪。请重装服务以继续使用,系统可能要求管理员认证。",
"title": "需要更新系统服务",
"message": "Clash Verge 系统服务版本与当前应用要求不符。请选择「重新安装服务」卸载现有服务并安装所需版本,也可以仅在本次会话继续使用 Sidecar。",
"checkingMessage": "正在确认系统服务状态及当前运行模式,请稍候……",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "服务无法确认内核文件及所在目录是否安全,已拒绝高权限运行。",
"permissionRepairGuide": "查看修复方法",
"sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。",
"upgradeStateNotRestored": "升级后未能恢复 Clash Verge 服务原有的开机启动和运行状态({{state}})。重新安装服务也无法恢复该状态;请用 systemctl status clash-verge-service 检查服务状态,并用 systemctl 调整。",
"uninstallSuccess": "已成功卸载服务"
},
"updater": {
+1
View File
@@ -23,6 +23,7 @@
"serviceMigration": {
"locationRefusedMessage": "系統服務的安裝路徑或目錄權限未通過安全檢查,無法啟動核心。請重新安裝服務以還原服務檔案和權限,然後重試。Windows 可能會要求管理員授權。",
"notAutoStartedMessage": "Clash Verge 系統服務的啟動類型已被改為手動或停用(常見於最佳化軟體的開機加速功能),開機後不會自動執行,本次已改用 Sidecar。請重新安裝服務以恢復自動啟動。Windows 可能會要求管理員授權。",
"upgradeIncompleteMessage": "Clash Verge 已升級,但原先安裝的 Clash Verge 服務未就緒。請重新安裝服務以繼續使用,系統可能要求管理員驗證。",
"title": "需要更新系統服務",
"message": "Clash Verge 系統服務版本與目前應用程式要求不符。請選擇「重新安裝服務」移除現有服務並安裝所需版本,也可以僅在本次工作階段繼續使用 Sidecar。",
"checkingMessage": "正在確認系統服務狀態及目前執行模式,請稍候……",
+1
View File
@@ -697,6 +697,7 @@
"permissionRejectedReason": "服務無法確認核心檔案及所在目錄是否安全,已拒絕以高權限執行。",
"permissionRepairGuide": "查看修復方法",
"sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。",
"upgradeStateNotRestored": "升級後未能恢復 Clash Verge 服務原有的開機啟動和執行狀態({{state}})。重新安裝服務也無法恢復該狀態;請用 systemctl status clash-verge-service 檢查服務狀態,並用 systemctl 調整。",
"uninstallSuccess": "已成功解除安裝服務"
},
"updater": {
+8 -2
View File
@@ -78,11 +78,13 @@ export const commands = {
}),
invokeUwpTool: () => __TAURI_INVOKE<null>('invoke_uwp_tool'),
copyClashEnv: () => __TAURI_INVOKE<null>('copy_clash_env'),
syncTrayProxySelection: () =>
__TAURI_INVOKE<null>('sync_tray_proxy_selection'),
/** Merges one selection pair into fresh backend state to avoid stale-list overwrites. */
recordSelectedNode: (groupName: string, node: string) =>
__TAURI_INVOKE<null>('record_selected_node', { groupName, node }),
switchProxyNode: (groupName: string, proxyName: string, unfix: boolean) =>
__TAURI_INVOKE<null>('switch_proxy_node', { groupName, proxyName, unfix }),
forgetSelectedNode: (groupName: string) =>
__TAURI_INVOKE<null>('forget_selected_node', { groupName }),
saveDnsConfig: (dnsConfig: YamlMapping) =>
__TAURI_INVOKE<null>('save_dns_config', { dnsConfig }),
applyDnsConfig: (apply: boolean) =>
@@ -109,6 +111,8 @@ export const commands = {
__TAURI_INVOKE<boolean>('take_service_repair_notice'),
takeServiceOwnerNotice: () =>
__TAURI_INVOKE<string | null>('take_service_owner_notice'),
takeServiceUpgradeStateNotice: () =>
__TAURI_INVOKE<string | null>('take_service_upgrade_state_notice'),
takeDiscardedKeysNotice: () =>
__TAURI_INVOKE<string | null>('take_discarded_keys_notice'),
getDnsConfigContent: () =>
@@ -616,6 +620,7 @@ export type NoticeStatus =
| 'service_core::repair_required'
| 'service_core::app_data_not_owned'
| 'service_core::sidecar_fallback'
| 'service_core::upgrade_state_not_restored'
| 'dns_override::auto_disabled'
| 'enhance::discarded_keys'
| 'tun_mode::auto_disabled'
@@ -1033,6 +1038,7 @@ export type ServiceFallbackNotice =
| { kind: 'unavailable' }
| { kind: 'coreRejected'; reason: string }
| { kind: 'notAutoStarted' }
| { kind: 'upgradeIncomplete' }
export type ServiceHealthKind =
| 'unknown'
@@ -6,6 +6,7 @@ import {
takeDnsOverrideNotice,
takeServiceFallbackNotice,
takeServiceRepairNotice,
takeServiceUpgradeStateNotice,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
@@ -27,6 +28,7 @@ vi.mock('@/services/cmds', () => ({
takeServiceFallbackNotice: vi.fn().mockResolvedValue(null),
takeServiceRepairNotice: vi.fn().mockResolvedValue(false),
takeServiceOwnerNotice: vi.fn().mockResolvedValue(null),
takeServiceUpgradeStateNotice: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/services/service-request', () => ({ requestService: vi.fn() }))
vi.mock('@/services/notice-service', () => ({
@@ -198,3 +200,23 @@ it('explains a startup core rejection instead of the generic fallback notice', a
0,
)
})
it('replays a pending Service upgrade state warning once the frontend subscribes', async () => {
vi.mocked(takeServiceUpgradeStateNotice)
.mockResolvedValueOnce('disabled,active')
.mockResolvedValue(null)
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message']([
'service_core::upgrade_state_not_restored',
'',
])
await flush()
expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.notifications.clashService.upgradeStateNotRestored',
{ state: 'disabled,active' },
0,
)
})
+13 -2
View File
@@ -29,11 +29,14 @@ export interface BusHandlerDeps {
/**
* Pure per-event handler table for the bus. Handlers may trigger documented
* side effects (SWR revalidation, notice toasts) but write store state only
* through dispatched pure actions. Created exactly once per bus mount.
* through dispatched pure actions. Created exactly once per bus mount; the
* throttles below live in the returned closure, mirroring the previous
* scattered listeners.
*/
export const createEventBus = (deps: BusHandlerDeps) => {
let lastProfileId: string | null = null
let lastProfileChangeTime = 0
let lastProxyRefreshTime = 0
const refreshThrottle = 800
const handleProfileChanged = (newProfileId: string) => {
@@ -49,6 +52,13 @@ export const createEventBus = (deps: BusHandlerDeps) => {
deps.revalidateKeys(['getProfiles'])
}
const handleRefreshProxyConfig = () => {
const now = Date.now()
if (now - lastProxyRefreshTime <= refreshThrottle) return
lastProxyRefreshTime = now
deps.revalidateKeys(['getProxyView'])
}
const handlers: BusHandlers = {
'verge://refresh-clash-config': () =>
deps.revalidateKeys([
@@ -64,7 +74,7 @@ export const createEventBus = (deps: BusHandlerDeps) => {
'verge://refresh-verge-config': () =>
deps.revalidateKeys(['getVergeConfig', 'getSystemProxy', 'getAutoProxy']),
'verge://refresh-profiles': () => deps.revalidateKeys(['getProfiles']),
'verge://refresh-proxy-config': () => deps.revalidateKeys(['getProxyView']),
'verge://refresh-proxy-config': handleRefreshProxyConfig,
'verge://notice-message': (payload) => deps.handleNotice(payload),
'profile-changed': handleProfileChanged,
'verge://timer-updated': (uid) =>
@@ -95,6 +105,7 @@ export const createEventBus = (deps: BusHandlerDeps) => {
deps.handleNotice(['service_core::sidecar_fallback', ''])
deps.handleNotice(['service_core::repair_required', ''])
deps.handleNotice(['service_core::app_data_not_owned', ''])
deps.handleNotice(['service_core::upgrade_state_not_restored', ''])
deps.handleNotice(['core_start::error', ''])
}
+10 -6
View File
@@ -152,17 +152,17 @@ export async function patchClashMode(payload: string) {
return commands.patchClashMode(payload)
}
export async function syncTrayProxySelection() {
return commands.syncTrayProxySelection()
}
/** Sends one selection pair so the backend can merge against current profile state. */
export async function recordSelectedNode(groupName: string, node: string) {
return commands.recordSelectedNode(groupName, node)
}
export async function switchProxyNode(
groupName: string,
proxyName: string,
unfix: boolean,
) {
return commands.switchProxyNode(groupName, proxyName, unfix)
export async function forgetSelectedNode(groupName: string) {
return commands.forgetSelectedNode(groupName)
}
export async function getProxyView(): Promise<ProxyViewV1> {
@@ -248,6 +248,10 @@ export async function takeServiceOwnerNotice() {
return commands.takeServiceOwnerNotice()
}
export async function takeServiceUpgradeStateNotice() {
return commands.takeServiceUpgradeStateNotice()
}
export async function takeDiscardedKeysNotice() {
return commands.takeDiscardedKeysNotice()
}
+21
View File
@@ -7,6 +7,7 @@ import {
takeServiceFallbackNotice,
takeServiceOwnerNotice,
takeServiceRepairNotice,
takeServiceUpgradeStateNotice,
} from '@/services/cmds'
import type { NoticeStatus } from '@/services/contract'
import { hideNotice, showNotice } from '@/services/notice-service'
@@ -125,6 +126,8 @@ const handlers: Record<
)
} else if (notice?.kind === 'notAutoStarted') {
requestService({ reason: 'serviceNotAutoStarted' })
} else if (notice?.kind === 'upgradeIncomplete') {
requestService({ reason: 'serviceUpgradeIncomplete' })
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
@@ -138,6 +141,24 @@ const handlers: Record<
)
})
},
'service_core::upgrade_state_not_restored': () => {
void takeServiceUpgradeStateNotice()
.then((state) => {
if (state) {
showNotice.warning(
'settings.feedback.notifications.clashService.upgradeStateNotRestored',
{ state },
0,
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service upgrade state notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
+1
View File
@@ -5,6 +5,7 @@ export type ServiceRequestReason =
| 'tunNeedsService'
| 'serviceLocationRefused'
| 'serviceNotAutoStarted'
| 'serviceUpgradeIncomplete'
export interface ServiceRequest {
readonly reason: ServiceRequestReason
+2
View File
@@ -128,6 +128,7 @@ export const translationKeys = [
'layout.components.navigation.menu.expandNavBar',
'layout.components.serviceMigration.locationRefusedMessage',
'layout.components.serviceMigration.notAutoStartedMessage',
'layout.components.serviceMigration.upgradeIncompleteMessage',
'layout.components.serviceMigration.title',
'layout.components.serviceMigration.message',
'layout.components.serviceMigration.checkingMessage',
@@ -820,6 +821,7 @@ export const translationKeys = [
'settings.feedback.notifications.clashService.permissionRejectedReason',
'settings.feedback.notifications.clashService.permissionRepairGuide',
'settings.feedback.notifications.clashService.sidecarFallback',
'settings.feedback.notifications.clashService.upgradeStateNotRestored',
'settings.feedback.notifications.clashService.uninstallSuccess',
'settings.feedback.notifications.updater.withClashProxySuccess',
'settings.feedback.notifications.updater.withClashProxyFailed',
+2
View File
@@ -233,6 +233,7 @@ export interface TranslationResources {
success: string
title: string
unavailableMessage: string
upgradeIncompleteMessage: string
}
sysproxyPrivilege: {
alternative: string
@@ -857,6 +858,7 @@ export interface TranslationResources {
permissionWritableReason: string
sidecarFallback: string
uninstallSuccess: string
upgradeStateNotRestored: string
}
updater: {
withClashProxyFailed: string