Compare commits

..
1 Commits
Author SHA1 Message Date
Tunglies 83a06e5716 feat(app): upgrade to tauri 3.0.0-alpha
Move the app onto the tauri v3 alpha line: runtime selection is now
explicit via tauri-runtime-wry, the Wry generics give way to the
DynRuntime defaults, and every official plugin rides 3.0.0-alpha.0
alongside the matching npm packages; pnpm moves to 12.4.2. The
tray-icon patch is dropped because upstream 0.25.1 ships the macOS 27
click-reentrancy fix. perf-harness is temporarily hollowed out until
tauri-plugin-wdio-webdriver publishes a tauri 3 release.
2026-09-16 17:41:52 +08:00
346 changed files with 7897 additions and 15637 deletions
+2 -2
View File
@@ -5,10 +5,10 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.10.0"
"pnpmVersion": "12.3.3"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0",
"version": "1.98.1",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+11 -41
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.21": {
"github/gh-aw-actions/setup@v0.89.1": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.21",
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
}
},
"containers": {
@@ -22,16 +22,6 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -42,16 +32,6 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -62,30 +42,20 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
},
"ghcr.io/github/github-mcp-server:v1.12.2": {
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
"ghcr.io/github/github-mcp-server:v1.12.1": {
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
}
}
}
+9 -9
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
@@ -101,7 +101,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -580,7 +580,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
-26
View File
@@ -1,26 +0,0 @@
name: Backend Guardrails
on:
pull_request:
paths:
- "src-tauri/**"
- ".github/workflows/backend-guardrails.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
banned-patterns:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: No Handle::refresh calls in feat
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
- name: No notice_message outside core/notify
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
- name: No manual update-flag chains
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
with:
version: v0.89.15
version: v0.89.1
+2 -2
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.0
uses: dtolnay/rust-toolchain@1.98.1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
-79
View File
@@ -85,82 +85,3 @@ jobs:
- name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: |
contract:
- 'src/services/contract.ts'
- 'scripts/gen-contract.mjs'
- 'src-tauri/src/core/notify/**'
- 'src-tauri/src/bin/**'
- 'src-tauri/src/lib.rs'
- '.github/workflows/frontend-check.yml'
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
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
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Regenerate contract and require empty diff
if: steps.check_inputs.outputs.contract == 'true'
run: |
pnpm gen:contract
git diff --exit-code -- src/services/contract.ts
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -144,7 +144,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -691,7 +691,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.10.0
rust 1.99.0
pnpm 12.4.1
rust 1.98.1
Generated
+266 -504
View File
File diff suppressed because it is too large Load Diff
+4 -13
View File
@@ -46,15 +46,6 @@ inherits = "fast-release"
codegen-units = 1
split-debuginfo = "unpacked"
[patch.crates-io]
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
# macOS Golden Gate fix for tray-icon click reentrancy issue
# Remove the following patch once the fix is merged upstream(tauri)
# https://github.com/tauri-apps/tray-icon/pull/365
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
[workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -66,13 +57,14 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.4.1"
tauri = { version = "3.0.0-alpha.1" }
tauri-runtime-wry = "3.0.0-alpha.1"
tauri-plugin-clipboard-manager = "3.0.0-alpha.0"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.2", features = [
tokio = { version = "1.53.1", features = [
"rt-multi-thread",
"macros",
"time",
@@ -108,7 +100,6 @@ windows-sys = { version = "0.61.2", features = [
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
] }
# *********************************
+74 -22
View File
@@ -1,51 +1,103 @@
## v2.5.8
## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🖥️/🍎 Windows/macOS**
**🐧 Linux**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
- 修复 Linux 安装或卸载服务时授权失败的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
+5 -7
View File
@@ -11,14 +11,13 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
<p align="center">
Languages:
<a href="./README.md">简体中文</a> ·
<a href="./docs/README_en.md">English</a> ·
<a href="./docs/README_es.md">Español</a> ·
<a href="./docs/README_fa.md">فارسی</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./docs/README_ja.md">日本語</a> ·
<a href="./docs/README_ko.md">한국어</a> ·
<a href="./docs/README_pt.md">Português</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./README.md">简体中文</a>
<a href="./docs/README_fa.md">فارسی</a>
</p>
## Preview
@@ -32,7 +31,6 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
#### 我应当怎样选择发行版
@@ -52,14 +50,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
#### AI云边 -- 全新架构机场。
- 💻 多次**技术迭代后**全新亮相。
- 🗺 全**高速稳定**正价节点。
- 🌏 **海外团队**,不跑路
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
- 👨‍🦲 团队架构师为**大厂**网络架构师
- 💰 极致**稳定**,亲民价**价格**
- 🌐 全面支持**流媒体及各AI访问**
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "4.2.4"
rust-i18n = "4.2.2"
sys-locale = "0.3.2"
[lints]
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: على وشك الخروج
body: Clash Verge على وشك الخروج.
appHidden:
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
core:
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
@@ -70,8 +71,6 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
+11 -12
View File
@@ -8,8 +8,8 @@ notifications:
body: Auf {mode} umgeschaltet.
systemProxyToggled:
title: Systemproxy
'on': Der Systemproxy wurde aktiviert.
'off': Der Systemproxy wurde deaktiviert.
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Systemproxy
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
@@ -18,8 +18,8 @@ notifications:
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
tunModeToggled:
title: TUN-Modus
'on': Der TUN-Modus wurde aktiviert.
'off': Der TUN-Modus wurde deaktiviert.
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
lightweightModeEntered:
title: Leichtmodus
body: Leichtmodus aktiviert.
@@ -29,16 +29,17 @@ notifications:
appQuit:
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
appHidden:
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
updateReady:
title: Clash-Verge-Update
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
installNow: Jetzt installieren
later: Später
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
core:
alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.'
launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
core:
alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.'
launchGuard:
currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: A punto de salir
body: Clash Verge está a punto de salir.
appHidden:
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
core:
alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.'
launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: در آستانه خروج
body: Clash Verge در آستانه خروج است.
appHidden:
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
core:
alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.'
launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
@@ -70,8 +71,6 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Akan Keluar
body: Clash Verge akan keluar.
appHidden:
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
core:
alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.'
launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 終了間近
body: Clash Verge はまもなく終了します。
appHidden:
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
core:
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
@@ -70,8 +71,6 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 곧 종료
body: Clash Verge가 곧 종료됩니다.
appHidden:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
core:
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
@@ -70,8 +71,6 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
-92
View File
@@ -1,92 +0,0 @@
_version: 1
notifications:
dashboardToggled:
title: Painel
body: A visibilidade do painel foi atualizada.
clashModeChanged:
title: Troca de modo
body: Alterado para {mode}.
systemProxyToggled:
title: Proxy do sistema
'on': O proxy do sistema foi ativado.
'off': O proxy do sistema foi desativado.
systemProxyFailed:
title: Proxy do sistema
body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo.
systemProxyGuardStopped:
title: Proteção do proxy do sistema
body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo.
tunModeToggled:
title: Modo TUN
'on': O modo TUN foi ativado.
'off': O modo TUN foi desativado.
lightweightModeEntered:
title: Modo leve
body: Modo leve ativado.
profilesReactivated:
title: Perfis
body: Perfil reativado.
appQuit:
title: Prestes a sair
body: O Clash Verge está prestes a sair.
updateReady:
title: Atualização do Clash Verge
body: Uma nova versão (v{version}) foi baixada e está pronta para instalar.
installNow: Instalar agora
later: Depois
service:
adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador.
adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador.
core:
alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.'
launchGuard:
currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}'
homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}'
translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente.
rejected: |-
Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos.
{reason}
notInBundle: O executável não está dentro de um pacote de aplicativo.
canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}'
bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado.
replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos?
movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora?
createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}'
moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente.
replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}'
relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos.
cancel: Cancelar
move: Mover
ok: OK
tray:
dashboard: Painel
ruleMode: Modo regra
globalMode: Modo global
directMode: Modo direto
outboundModes: Modos de saída
rule: Regra
direct: Direto
global: Global
profiles: Perfis
proxies: Proxies
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
lightweightMode: Modo leve
copyEnv: Copiar variáveis de ambiente
confDir: Diretório de configuração
coreDir: Diretório do núcleo
logsDir: Diretório de logs
openDir: Abrir diretório
appLog: Log do aplicativo
coreLog: Log do núcleo
restartClash: Reiniciar o núcleo do Clash
restartApp: Reiniciar o aplicativo
vergeVersion: Versão do Verge
more: Mais
exit: Sair
tooltip:
systemProxy: Proxy do sistema
tun: TUN
profile: Perfil
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Скорый выход
body: Clash Verge скоро завершит работу.
appHidden:
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
updateReady:
title: Обновление Clash Verge
body: Новая версия (v{version}) скачана и готова к установке.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
core:
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
@@ -70,8 +71,6 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
+11 -12
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
@@ -18,8 +18,8 @@ notifications:
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -29,16 +29,17 @@ notifications:
appQuit:
title: Çıkış Yapılmak Üzere
body: Clash Verge kapanmak üzere.
appHidden:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
core:
alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.'
launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
@@ -70,8 +71,6 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Чыгар алдыннан
body: Clash Verge чыгарга җыена.
appHidden:
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
core:
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
@@ -70,8 +71,6 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即将退出
body: Clash Verge 即将退出。
appHidden:
title: 应用已隐藏
body: Clash Verge 正在后台运行。
updateReady:
title: Clash Verge 更新
body: 新版本 (v{version}) 已下载完成,是否立即安装?
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
core:
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
launchGuard:
currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
@@ -70,8 +71,6 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
+3 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即將退出
body: Clash Verge 即將退出。
appHidden:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
updateReady:
title: Clash Verge 更新
body: 新版本(v{version})已下載完成,是否立即安裝?
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
core:
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
launchGuard:
currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
@@ -70,8 +71,6 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
@@ -14,7 +14,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.190"
libc = "0.2.189"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
-214
View File
@@ -1,217 +1,3 @@
## v2.5.7
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS**
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
</details>
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🍎 macOS**
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
## v2.5.5
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
</details>
## v2.5.4
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
### 🐞 修复问题
+6 -7
View File
@@ -11,14 +11,13 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
<p align="center">
Languages:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Preview
@@ -52,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
## Promotion
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### **Core Advantages:**
@@ -68,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
- 🎬 Unlocks **streaming platforms and mainstream AI services**
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
<p align="center">
Idiomas:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Vista previa
@@ -52,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
## Promociones
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
زبان‌ها:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## پیش‌نمایش
@@ -51,17 +50,17 @@
## تبلیغات
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
- بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K
- اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
言語:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## プレビュー
@@ -52,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
## プロモーション
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
언어:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## 미리보기
@@ -52,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
## 프로모션
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
-125
View File
@@ -1,125 +0,0 @@
<h1 align="center">
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
<br>
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
<br>
</h1>
<h3 align="center">
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
</h3>
<p align="center">
Idiomas:
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Pré-visualização
| Escuro | Claro |
| -------------------------------------- | -------------------------------------- |
| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) |
## Instalação
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
#### Como escolher o canal de lançamento
| Canal | Descrição | Link |
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
#### Guias de instalação e perguntas frequentes
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
### Canal do Telegram
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
---
## Promoções
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
- Desbloqueia serviços de streaming e acesso ao ChatGPT
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
---
## Funcionalidades
- Baseado em Rust de alto desempenho e no framework Tauri 2
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
- Editores visuais para nós e regras
- Backup e sincronização via WebDAV
### Perguntas frequentes
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
### Doações
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
## Desenvolvimento
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
```shell
pnpm i
pnpm run prebuild
pnpm dev
```
## Contribuições
Issues e pull requests são bem-vindos.
## Agradecimentos
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
## Licença
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
+6 -7
View File
@@ -11,14 +11,13 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
<p align="center">
Языки:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Предпросмотр
@@ -48,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+1 -2
View File
@@ -17,8 +17,7 @@
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"],
"src/services/contract.ts": ["exports", "types"]
"src/types/generated/**": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
+52 -54
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.8",
"version": "2.5.4",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -30,8 +30,6 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@@ -51,90 +49,90 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.13.1",
"ahooks": "^3.10.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@tauri-apps/api": "3.0.0-alpha.0",
"@tauri-apps/plugin-clipboard-manager": "^3.0.0-alpha.0",
"@tauri-apps/plugin-dialog": "^3.0.0-alpha.0",
"@tauri-apps/plugin-fs": "^3.0.0-alpha.0",
"@tauri-apps/plugin-http": "~3.0.0-alpha.0",
"@tauri-apps/plugin-opener": "3.0.0-alpha.0",
"@tauri-apps/plugin-updater": "3.0.0-alpha.0",
"ahooks": "^3.9.7",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
"dayjs": "1.11.23",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31",
"monaco-editor": "^0.57.0",
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react-error-boundary": "6.1.5",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.14",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"react-router": "^8.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"swr": "^2.4.2",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.1",
"@tauri-apps/cli": "3.0.0-alpha.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/node": "^24.12.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.11.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-es-x": "^10.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^10.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"knip": "6.35.1",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"ws": "8.22.0"
"vitest": "^5.0.0",
"webdriverio": "9.31.9",
"ws": "8.21.3"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -146,5 +144,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.10.0"
"packageManager": "pnpm@12.4.2"
}
+1552 -1398
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -10,3 +10,15 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- '@tauri-apps/cli-darwin-arm64@3.0.0-alpha.1'
- '@tauri-apps/cli-darwin-x64@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm-gnueabihf@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-arm64-musl@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-riscv64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-x64-gnu@3.0.0-alpha.1'
- '@tauri-apps/cli-linux-x64-musl@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-arm64-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-ia32-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli-win32-x64-msvc@3.0.0-alpha.1'
- '@tauri-apps/cli@3.0.0-alpha.1'
+1 -17
View File
@@ -56,12 +56,6 @@
"matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false
},
{
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
"matchFileNames": [".github/workflows/**"],
"matchPackageNames": ["/gh-aw/"],
"enabled": false
},
{
"description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"],
@@ -84,7 +78,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm"],
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"groupName": "pnpm"
},
{
@@ -137,16 +131,6 @@
],
"depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"description": "Update pnpm in devcontainer.json",
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
"matchStrings": [
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "pnpm",
"datasourceTemplate": "npm"
}
],
"postUpdateOptions": ["pnpmDedupe"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.0"
channel = "1.98.1"
components = ["rustfmt", "clippy"]
-84
View File
@@ -1,84 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src')
const tauriEventImport = (name) =>
new RegExp(
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
let violations = 0
for (const gate of selected) {
const rule = GATES[gate]
if (!rule) {
console.error(`unknown gate: ${gate}`)
process.exit(2)
}
for (const file of walk(SRC)) {
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
if (rule.allowed(relative)) continue
const lines = fs.readFileSync(file, 'utf8').split('\n')
lines.forEach((line, index) => {
if (rule.patterns.some((pattern) => pattern.test(line))) {
violations += 1
console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
)
}
})
}
}
if (violations > 0) {
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
+141 -85
View File
@@ -1,23 +1,36 @@
import { execFileSync, spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
export const developmentServiceWatchPaths = [
'src',
'resources',
'Cargo.toml',
'Cargo.lock',
].map((name) => join(serviceRepository, name))
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
@@ -26,15 +39,10 @@ function run(command, args, options = {}) {
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.once('error', reject)
child.once('close', (code, signal) => {
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise(stdout)
resolvePromise()
return
}
const error = new Error(
@@ -46,86 +54,134 @@ function run(command, args, options = {}) {
})
}
export async function prepareDevelopmentService() {
await access(serviceManifest)
const output = await run(
'cargo',
[
'build',
'--manifest-path',
serviceManifest,
'--target-dir',
process.env.CARGO_TARGET_DIR ||
join(serviceRepository, 'target', 'development'),
'--features',
'standalone,client,development-channel',
'--bins',
'--message-format=json-render-diagnostics',
],
{ stdio: ['inherit', 'pipe', 'inherit'] },
)
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
const artifacts = new Map()
for (const line of output.split(/\r?\n/).filter(Boolean)) {
const artifact = JSON.parse(line)
if (
artifact.reason === 'compiler-artifact' &&
resolve(artifact.manifest_path) === serviceManifest &&
artifact.target.kind.includes('bin') &&
artifact.executable
) {
artifacts.set(artifact.target.name, resolve(artifact.executable))
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
const executables = [
'clash-verge-service',
'clash-verge-service-install',
'clash-verge-service-uninstall',
'service-integration-driver',
].map((name) => {
const executable = artifacts.get(name)
if (!executable)
throw new Error(`Cargo did not report the ${name} executable`)
return executable
})
const serviceDirectory = dirname(executables[0])
for (const executable of executables) {
if (dirname(executable) !== serviceDirectory) {
throw new Error(
'Development service tools were built in different directories',
)
}
await access(executable)
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
function windowsElevationInvocation(installer, environment = process.env) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
return serviceDirectory
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService()
const extension = platform === 'win32' ? '.exe' : ''
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
`clash-verge-service-install${extension}`,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
/^host: (.+)$/m,
)?.[1]
if (!host) throw new Error('rustc did not report its host target')
const args = ['--prepare-install', '--ensure']
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
args.push(
'--core',
`${name}${extension}`,
join(
repositoryRoot,
'src-tauri',
'sidecar',
`${name}-${host}${extension}`,
),
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
await run(installer, args)
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
-4
View File
@@ -10,7 +10,6 @@ import {
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
developmentServiceWatchPaths,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
@@ -32,9 +31,6 @@ export function buildTauriInvocation(
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
for (const path of developmentServiceWatchPaths) {
args.push('--additional-watch-folders', path)
}
return {
command: 'pnpm',
args,
-91
View File
@@ -1,91 +0,0 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
/**
* Regenerates src/services/contract.ts from the backend wire contract.
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
* src-tauri, which serializes the same enum variants and event payloads the
* app emits at runtime. CI reruns this and fails on a non-empty diff.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const OUT = path.join(ROOT, 'src/services/contract.ts')
const contract = JSON.parse(
execFileSync(
'cargo',
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
{
cwd: path.join(ROOT, 'src-tauri'),
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
},
),
)
if (contract.version !== 1) {
throw new Error(`unsupported wire contract version: ${contract.version}`)
}
/** TS type per event, derived from the serialized sample payload's JSON shape. */
const payloadType = (name, sample) => {
if (sample === null) return 'null'
if (typeof sample === 'string') return 'string'
if (Array.isArray(sample)) {
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
throw new Error(`unexpected array payload for ${name}`)
}
if (typeof sample === 'object') {
const keys = Object.keys(sample)
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
if (name === 'verge://run-state-changed') return 'RunState'
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
}
throw new Error(`unexpected payload for ${name}`)
}
const needsRunState = contract.events.some(
(e) => e.name === 'verge://run-state-changed',
)
if (!needsRunState)
throw new Error('run-state-changed event missing from contract')
const lines = []
lines.push(
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
)
lines.push('// Regen: pnpm gen:contract')
lines.push('')
lines.push("import type { RunState } from './cmds'")
lines.push('')
lines.push(
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
)
lines.push('export type NoticeStatus =')
for (const status of contract.noticeStatuses) {
lines.push(` | ${JSON.stringify(status)}`)
}
lines.push('')
lines.push('/** Event name → payload type for every backend-emitted event. */')
lines.push('export interface VergeEventPayloads {')
for (const event of contract.events) {
const key = JSON.stringify(event.name)
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
}
lines.push('}')
lines.push('')
const content = `${lines.join('\n')}\n`
fs.writeFileSync(OUT, content)
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
{ cwd: ROOT, stdio: 'ignore' },
)
console.log(
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
)
+11 -53
View File
@@ -1,9 +1,9 @@
import { execFileSync, execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import zlib from 'node:zlib'
import { execFileSync, execSync } from 'child_process'
import { createHash } from 'crypto'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import zlib from 'zlib'
import AdmZip from 'adm-zip'
import { glob } from 'glob'
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
const zip = new AdmZip(tempZip)
zip
.getEntries()
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
zip.extractAllTo(tempDir, true)
if (fs.existsSync(tempDll)) {
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
@@ -586,41 +586,6 @@ async function resolveServiceBundle() {
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const serviceDependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
if (sourcePath) {
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
execFileSync(
'cargo',
[
'build',
'--manifest-path',
manifest,
'--target-dir',
targetDirectory,
'--target',
SIDECAR_HOST,
'--release',
'--features',
'standalone,client',
'--bins',
],
{ stdio: 'inherit' },
)
await fsp.mkdir(SERVICE_DIR, { recursive: true })
for (const { sourceFile, targetPath } of files) {
await fsp.copyFile(
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
targetPath,
)
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
await updateHashCache(targetPath)
}
return
}
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
@@ -639,9 +604,8 @@ async function resolveServiceBundle() {
const zip = new AdmZip(tempArchive)
zip
.getEntries()
.forEach(
(entry) =>
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
.forEach((entry) =>
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
)
zip.extractAllTo(tempDir, true)
} else {
@@ -688,7 +652,7 @@ async function resolveCoreHashes() {
.digest('hex')
lines.push(`!define ${define} "${digest}"`)
}
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
log_success(`Generated ${CORE_HASHES_NSH}`)
}
@@ -697,11 +661,6 @@ const resolveMmdb = () =>
file: 'Country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
})
const resolveASNMmdb = () =>
resolveResource({
file: 'ASN.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
})
const resolveGeosite = () =>
resolveResource({
file: 'geosite.dat',
@@ -747,7 +706,6 @@ const tasks = [
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
{ name: 'service', func: resolveServiceBundle, retry: 5 },
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
{
+15 -50
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,20 +108,17 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
const versionWithoutV = newVersion.startsWith('v')
packageJson.version = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
)
if (updatedData === data) {
throw new Error('version field was not found in package.json')
}
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating package.json version:', error)
throw error
@@ -156,34 +153,6 @@ async function updateCargoVersion(newVersion) {
}
}
async function updateCargoLockVersion(newVersion) {
const _dirname = process.cwd()
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const packageVersionPattern =
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
try {
const data = await fs.readFile(cargoLockPath, 'utf8')
const updatedData = data.replace(
packageVersionPattern,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('clash-verge package entry was not found in Cargo.lock')
}
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating Cargo.lock version:', error)
throw error
}
}
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -199,16 +168,13 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
)
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
)
@@ -273,7 +239,6 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
+19 -41
View File
@@ -30,51 +30,29 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
original_dns=$(networksetup -getdnsservers "$hardware_port")
backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
if [ -f "$backup" ]; then
rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
is_valid_dns=true
break
fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1
networksetup -setdnsservers "$hardware_port" "$1"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then
echo "empty" >"$backup" || exit 1
fi
networksetup -setdnsservers "$hardware_port" "$1"
+8 -57
View File
@@ -1,62 +1,13 @@
#!/bin/bash
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
result=0
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
original_dns=$(cat .original_dns.txt)
networksetup -setdnsservers "$hardware_port" $original_dns
rm -rf .original_dns.txt
fi
for backup in .original_dns/*; do
[ -f "$backup" ] || continue
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1
continue
fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then
original_dns=$(cat "$backup") || { result=1; continue; }
# Older releases could back up the placeholder itself.
[ "$original_dns" = "$1" ] && original_dns=empty
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
rm -f "$backup" || result=1
done
rmdir .original_dns 2>/dev/null
exit $result
+12 -3
View File
@@ -14,13 +14,22 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const tag = { name: latestRelease.tag_name }
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
})
const tag = tags.find((t) => t.name.startsWith('v'))
console.log(tag)
console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md
+2 -3
View File
@@ -40,11 +40,10 @@ async function resolveUpdater() {
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
const stableTagRegex = /^v\d+\.\d+\.\d+$/
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const stableTag = { name: latestRelease.tag_name }
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log('All tags:', tags.map((t) => t.name).join(', '))
-24
View File
@@ -1,24 +0,0 @@
# Backend Architecture Guardrails
These rules supplement the repository's root `AGENTS.md`.
- **Configuration ownership.** `config` owns source configuration, drafts and
persistence. The effects layer owns patch orchestration. Rollbackable
transactions publish state after persistence and commit; already applied
runtime and independently committed state remain authoritative even when a
later step fails. Preserve that state, its notifications and the error.
- **Effect ownership.** The declarative registry classifies every Verge patch field,
including fields without effects and platform-specific fields. Effects
reconcile toward the requested state with explicit dependencies and ordering.
Configuration rollback does not imply reversal of OS or lifecycle effects.
- **Shared wire contract.** Notifications are typed. Event names, status values
and payload shapes stay compatible with frontend producers and consumers.
- **Synchronization ownership.** Configuration-write serialization precedes
configuration-update ownership, which precedes Core lifecycle ownership.
When both are held, the lifecycle lock precedes the DNS lock. Independent
transactions retain their ownership boundaries; asynchronous work does not
hold synchronous data guards across suspension.
- **Retry ownership.** Bounded retries share backend policy while preserving
attempts, delays and terminal errors. Background monitors own their lifetime
and cancellation separately; waiting for a condition or traversing distinct
candidates is not a retry policy.
+26 -25
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.8"
version = "2.5.4"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
default-run = "clash-verge"
build = "build.rs"
edition = "2024"
rust-version = "1.99"
rust-version = "1.95"
[lib]
name = "app_lib"
@@ -23,13 +23,15 @@ tauri-dev = ["dep:tauri-plugin-devtools"]
tokio-trace = ["clash-verge-logging/tokio-trace"]
chrome-trace = ["clash-verge-logging/chrome-trace"]
clippy = ["tauri/test"]
perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
# The perf-harness feature and its main.rs wiring are parked until
# tauri-plugin-wdio-webdriver publishes a tauri 3 release; restore both
# from git history together with the crate dependency.
[package.metadata.bundle]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.7.1", features = [] }
tauri-build = { version = "3.0.0-alpha.0", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
@@ -42,12 +44,12 @@ tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [
"protocol-asset",
"devtools",
"tracing",
"tray-icon",
"image-ico",
"image-png",
] }
tauri-runtime-wry = { workspace = true, features = ["devtools"] }
parking_lot = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -66,6 +68,7 @@ dunce = "1.0.5"
nanoid = "0.5"
chrono = { workspace = true }
boa_engine = "0.22.0"
once_cell = { version = "1.21.4", features = ["parking_lot"] }
percent-encoding = "2.3.2"
reqwest = { workspace = true }
regex = { workspace = true }
@@ -73,37 +76,36 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0"
tauri-plugin-shell = "3.0.0-alpha.0"
tauri-plugin-opener = "3.0.0-alpha.0"
tauri-plugin-dialog = "3.0.0-alpha.0"
tauri-plugin-fs = "3.0.0-alpha.0"
tauri-plugin-process = "3.0.0-alpha.0"
tauri-plugin-deep-link = "3.0.0-alpha.0"
tauri-plugin-window-state = "3.0.0-alpha.0"
zip = "8.6.0"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.5.1"
tauri-plugin-notification = "3.0.0-alpha.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.0"
tauri-plugin-http = "3.0.0-alpha.0"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
tauri-plugin-devtools = { version = "3.0.0-alpha.0", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "feat/tauri-v3" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.1", version = "2.7.1", features = [
"client",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
tokio-rustls = "0.26.5"
rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
@@ -111,11 +113,10 @@ bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5"
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
"NSDictionary",
@@ -146,9 +147,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.13.1"
tauri-plugin-autostart = "3.0.0-alpha.0"
tauri-plugin-global-shortcut = "3.0.0-alpha.0"
tauri-plugin-updater = "3.0.0-alpha.0"
[dev-dependencies]
criterion = { workspace = true }
-1
View File
@@ -70,7 +70,6 @@
"dialog:allow-open",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"process:allow-exit",
@@ -1,38 +0,0 @@
#!/bin/bash
set -e
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
fi
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
exit 0
fi
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
for service in clash-verge-service clash-verge-service-dev; do
for directory in bin cores; do
target="/var/lib/$service/$directory"
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
echo "Refusing to relabel a symlinked service directory: $target" >&2
exit 1
fi
spec="$target(/.*)?"
# Older semanage versions reject -a when an exact rule already exists.
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
printf '%s\n' "$add_error" >&2
exit 1
fi
fi
if [ -d "$target" ]; then
/usr/sbin/restorecon -R "$target"
fi
done
done
-1
View File
@@ -901,7 +901,6 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
-8
View File
@@ -1,8 +0,0 @@
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
//! the payload is derived from the same serializers the app uses at runtime.
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
//! TypeScript artifact.
fn main() {
println!("{}", app_lib::frontend_wire_contract());
}
+2 -26
View File
@@ -1,12 +1,7 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent},
feat,
utils::dirs,
};
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel};
use tauri::{AppHandle, Manager as _};
#[tauri::command]
pub async fn open_app_dir() -> CmdResult<()> {
@@ -49,25 +44,6 @@ pub async fn restart_app() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn install_update(
app_handle: AppHandle,
version: String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
})
.await
.stringify_err()
}
#[tauri::command]
pub fn cancel_update_download() {
SilentUpdater::global().cancel_download();
}
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
+9 -25
View File
@@ -1,5 +1,4 @@
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
use crate::core::notify::NoticeStatus;
use crate::feat;
use crate::utils::{dirs, yaml_emitter};
use crate::{
@@ -57,14 +56,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
handle::Handle::notice_message("config_core::change_success", clash_core);
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let failed = err.context("core changed but failed to restart");
let error_msg: String = format!("{failed:#}").into();
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
}
@@ -73,7 +72,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
Err(err) => {
let error_msg: String = format!("{err:#}").into();
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
handle::Handle::notice_message("config_core::change_error", error_msg);
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
}
}
@@ -134,12 +133,8 @@ pub fn take_dns_override_notice() -> bool {
}
#[tauri::command]
pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(profile_uid, enabled, confirmation)
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
}
@@ -150,26 +145,16 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
logging!(warn, Type::Config, "DNS config file not found");
return Err("DNS config file not found".into());
}
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to read DNS config {}: {e}",
dns_path.display()
);
logging!(error, Type::Config, "Failed to read DNS config: {e}");
})?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to parse DNS config {}: {e}",
dns_path.display()
);
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
})?;
let mut patch = serde_yaml_ng::Mapping::new();
@@ -225,8 +210,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
.await
.stringify_err()
}
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::core::lightweight;
use crate::module::lightweight;
use super::CmdResult;
+1 -2
View File
@@ -1,7 +1,6 @@
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles;
use crate::core::notify::NoticeStatus;
use crate::utils::window_manager::WindowManager;
use crate::{
config::{
@@ -283,7 +282,7 @@ async fn handle_update_error<E: std::fmt::Display>(
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
discard_and_restore(current_profile).await?;
let message: String = e.to_string().into();
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
handle::Handle::notice_message("config_validate::boot_error", message.clone());
Ok(ValidationOutcome::invalid_from_message(message))
}
+32 -72
View File
@@ -4,7 +4,7 @@ use crate::{
config::Config,
core::{
handle::Handle,
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
@@ -12,7 +12,7 @@ use crate::{
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::{
collections::{BTreeMap, HashSet},
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
};
@@ -50,87 +50,20 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.collect()
}
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
let Some(config) = config else {
return BTreeMap::new();
};
let mut all_providers = config
.get("proxy-providers")
.and_then(|providers| providers.as_mapping())
.into_iter()
.flat_map(|providers| providers.keys())
.filter_map(|name| name.as_str())
.map(str::to_owned)
.collect::<Vec<_>>();
all_providers.sort();
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.as_mapping())
.filter_map(|group| {
let name = group.get("name")?.as_str()?.to_owned();
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
all_providers.clone()
} else {
group
.get("use")
.and_then(|providers| providers.as_sequence())
.into_iter()
.flatten()
.filter_map(|provider| provider.as_str())
.map(str::to_owned)
.collect()
};
let exclude_types = group
.get("exclude-type")
.and_then(|types| types.as_str())
.filter(|types| !types.is_empty())
.map(|types| types.split('|').map(str::to_owned).collect())
.unwrap_or_default();
Some((
name,
GroupScope {
providers,
exclude_types,
},
))
})
.collect()
}
#[tauri::command]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
providers: providers.ok(),
}))
}
@@ -151,7 +84,6 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
}
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(_) => {
@@ -177,3 +109,31 @@ async fn run_tray_sync_loop() {
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use serde_yaml_ng::Value;
use super::runtime_group_order;
#[test]
fn runtime_order_keeps_first_non_empty_non_global_name() {
let config: Value = serde_yaml_ng::from_str(
r#"
proxy-groups:
- name: Beta
- name: ""
- name: GLOBAL
- name: " Alpha "
- name: Beta
"#,
)
.expect("parse runtime");
assert_eq!(
runtime_group_order(config.as_mapping()),
["Beta".to_owned(), " Alpha ".to_owned()]
);
}
}
-5
View File
@@ -32,11 +32,6 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
}
#[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> {
crate::enhance::take_discarded_keys_notice()
}
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await;
+1 -1
View File
@@ -3,11 +3,11 @@ use crate::{
cmd::StringifyErr as _,
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
config::{Config, IProfiles, PrfItem},
core::auto_backup::{AutoBackupManager, AutoBackupTrigger},
core::{
CoreManager, handle,
validate::{CoreConfigValidator, ValidationOutcome},
},
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
utils::dirs,
};
use clash_verge_logging::{Type, logging, logging_error};
+18 -167
View File
@@ -8,81 +8,26 @@ use crate::{
},
};
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome {
Installed,
#[cfg(any(windows, test))]
Sidecar {
reason: String,
},
}
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
let manager = CoreManager::global();
let result = {
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER.handle_service_status(status).await
};
#[cfg(windows)]
return finish_service_installation(result, error_code, |reason| async move {
manager.continue_with_sidecar(Some(&reason)).await
})
.await;
#[cfg(not(windows))]
result
.map(|()| ServiceInstallOutcome::Installed)
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))
}
#[cfg(any(windows, test))]
async fn finish_service_installation<Fallback, FallbackFuture>(
installation: anyhow::Result<()>,
error_code: &str,
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce(String) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
let Err(error) = installation else {
return Ok(ServiceInstallOutcome::Installed);
};
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
else {
return Err(proxy_aware_coded_error(&error, error_code));
};
let reasons = failure
.status
.cores
.iter()
.filter_map(|core| match &core.availability {
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
_ => None,
})
.collect::<Vec<_>>();
if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code));
}
let reason = reasons.join("\n");
fallback(reason.clone())
.await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
Ok(ServiceInstallOutcome::Sidecar { reason })
}
#[tauri::command]
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn install_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
@@ -95,120 +40,26 @@ pub async fn uninstall_service() -> CmdResult {
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn reinstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
pub async fn repair_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
#[tauri::command]
pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar(None)
.continue_with_sidecar()
.await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
#[tauri::command]
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
crate::core::notification::sidecar_failure_snapshot()
}
#[tauri::command]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{ServiceInstallOutcome, finish_service_installation};
use clash_verge_service_ipc::{
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
};
use std::cell::Cell;
#[tokio::test]
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
let reason = "core path C:\\ is writable by Everyone";
for (availability, fallback_ready) in [
(None, false),
(Some(CoreAvailability::Missing), false),
(Some(CoreAvailability::DigestMismatch), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
] {
let fallback_called = Cell::new(false);
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus {
service_sha256: String::new(),
protocol: ProtocolInfo::current(),
cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(),
availability,
}],
core_busy: false,
},
}),
None => anyhow::anyhow!("{reason}"),
}
.context("install service failed");
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
fallback_called.set(true);
anyhow::ensure!(fallback_ready, "another core is running");
Ok(())
})
.await;
assert_eq!(fallback_called.get(), rejected);
if rejected && fallback_ready {
assert_eq!(
result,
Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}")
})
);
} else {
let error = result.expect_err("an unavailable fallback must remain an error");
assert_eq!(
error.code.as_deref(),
Some(if rejected {
"SERVICE_SIDECAR_FAILED"
} else {
"SERVICE_INSTALL_FAILED"
})
);
if rejected {
assert!(error.detail.contains("another core is running"));
}
}
}
}
}
+16 -17
View File
@@ -1,4 +1,3 @@
use crate::core::notify::NoticeStatus;
use crate::core::{
handle,
validate::{ValidationErrorKind, ValidationOutcome},
@@ -12,27 +11,27 @@ pub enum ValidationNoticeTarget {
Script,
}
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str {
match kind {
ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
ValidationErrorKind::FileMissing => "config_validate::file_not_found",
ValidationErrorKind::FileRead => match target {
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
_ => NoticeStatus::ConfigValidateYamlReadError,
ValidationNoticeTarget::Script => "config_validate::script_error",
_ => "config_validate::yaml_read_error",
},
ValidationErrorKind::YamlSyntax => match target {
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error",
ValidationNoticeTarget::Script => "config_validate::script_error",
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error",
},
ValidationErrorKind::YamlMapping => match target {
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error",
ValidationNoticeTarget::Script => "config_validate::script_error",
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error",
},
ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
ValidationErrorKind::ScriptSyntax => "config_validate::script_syntax_error",
ValidationErrorKind::ScriptMissingMain => "config_validate::script_missing_main",
ValidationErrorKind::ProcessTerminated => "config_validate::process_terminated",
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => "config_validate::error",
}
}
@@ -41,12 +40,12 @@ pub fn handle_validation_notice(outcome: &ValidationOutcome, target: ValidationN
ValidationOutcome::Invalid { kind, message } => {
let status = notice_key(*kind, target);
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
handle::Handle::notice(status, message.as_str());
handle::Handle::notice_message(status, message.to_owned());
}
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
let message = outcome.to_string();
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
handle::Handle::notice(NoticeStatus::ConfigValidateError, message.as_str());
handle::Handle::notice_message("config_validate::error", message);
}
ValidationOutcome::Valid => {}
}
+10 -19
View File
@@ -1,5 +1,4 @@
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
constants::{files, timing},
@@ -7,7 +6,6 @@ use crate::{
CoreManager,
handle::{self, Handle},
listener::MIXED_PORT_KEY,
runtime_bundle::resolve_provider_path_conflicts,
tray,
validate::CoreConfigValidator,
},
@@ -70,12 +68,6 @@ impl Config {
CONFIG_WRITE_LOCK.lock().await
}
pub(crate) fn try_lock_config_write() -> Result<MutexGuard<'static, ()>> {
CONFIG_WRITE_LOCK
.try_lock()
.map_err(|_| anyhow!("configuration update is already running"))
}
pub async fn init_config_before_window() -> Result<()> {
Self::ensure_default_profile_items().await?;
@@ -113,7 +105,7 @@ impl Config {
if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await;
handle::Handle::notice(msg_type, msg_content.as_str());
handle::Handle::notice_message(msg_type, msg_content);
}
Self::runtime().await.apply();
@@ -144,14 +136,14 @@ impl Config {
Ok(())
}
async fn generate_and_validate() -> Result<Option<(NoticeStatus, String)>> {
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
if let Err(err) = Self::generate().await {
let error_msg: String = err.to_string().into();
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
CoreManager::global()
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.use_default_config("config_validate::boot_error", &error_msg)
.await?;
return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
return Ok(Some(("config_validate::boot_error", error_msg)));
}
logging!(debug, Type::Config, "生成运行时配置成功");
@@ -178,25 +170,25 @@ impl Config {
error_msg
);
CoreManager::global()
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.use_default_config("config_validate::boot_error", &error_msg)
.await?;
Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
Ok(Some(("config_validate::boot_error", error_msg)))
}
Err(err) => {
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
CoreManager::global()
.use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
.use_default_config("config_validate::process_terminated", "")
.await?;
Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, String::new())))
Ok(Some(("config_validate::process_terminated", String::new())))
}
}
} else {
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
CoreManager::global()
.use_default_config(NoticeStatus::ConfigValidateError, "")
.use_default_config("config_validate::error", "")
.await?;
Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
Ok(Some(("config_validate::error", String::new())))
}
}
@@ -236,7 +228,6 @@ impl Config {
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
sanitize_tunnels_proxy(&mut config);
// Apply only to generated core config so the saved choice survives the next launch.
if let Some(port) = MixedPort::session_fallback() {
+40 -140
View File
@@ -1,7 +1,5 @@
use crate::core::notify::NoticeStatus;
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use smartstring::alias::String;
@@ -56,7 +54,6 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
#[derive(Debug, Clone)]
pub(crate) struct DnsOverrideState {
profile_uid: String,
pub source: Option<String>,
pub enabled: bool,
requested: bool,
@@ -64,10 +61,9 @@ pub(crate) struct DnsOverrideState {
}
impl DnsOverrideState {
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
let enabled = requested && (source.is_none() || source == confirmation);
Self {
profile_uid: profile_uid.into(),
source,
enabled,
requested,
@@ -76,55 +72,24 @@ impl DnsOverrideState {
}
fn apply_to(&self, verge: &mut IVerge) -> bool {
if self.profile_uid.is_empty() {
return false;
}
let settings = verge.dns_settings_for(&self.profile_uid);
// A later settings write must not be overwritten by an earlier runtime generation.
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
return false;
}
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
if self.enabled == self.requested
&& !clear_confirmation
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|| verge.dns_override_confirmation != self.confirmation
{
return false;
}
verge.profile_dns_settings.insert(
self.profile_uid.clone(),
ProfileDnsSettings {
enabled: self.enabled,
confirmation: if clear_confirmation {
None
} else {
self.confirmation.clone()
},
},
);
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
if self.enabled == self.requested && !clear_confirmation {
return false;
}
verge.enable_dns_settings = Some(self.enabled);
if clear_confirmation {
verge.dns_override_confirmation = None;
}
true
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileDnsSettings {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirmation: Option<String>,
}
impl IVerge {
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
self.profile_dns_settings
.get(profile_uid)
.cloned()
.unwrap_or(ProfileDnsSettings {
enabled: self.enable_dns_settings.unwrap_or(false),
confirmation: None,
})
}
}
impl Config {
pub(crate) async fn sync_dns_override() -> Result<()> {
let _config_write = Self::lock_config_write().await;
@@ -139,20 +104,19 @@ impl Config {
}
// The runtime is already committed; a disk failure cannot undo its effective settings.
transaction.commit();
Handle::refresh_verge();
if state.requested && !state.enabled {
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
Handle::notice_message("dns_override::auto_disabled", "");
}
let result = verge.data_arc().save_file().await;
Handle::refresh_verge();
result
verge.data_arc().save_file().await
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
use super::{DnsOverrideState, dns_override_source};
use crate::config::IVerge;
use anyhow::Result;
use serde_yaml_ng::Mapping;
@@ -188,46 +152,35 @@ mod tests {
let reordered =
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
assert_eq!(source, dns_override_source("one", &reordered)?);
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
Ok(())
}
#[test]
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
let source = Some("provider-dns".into());
let confirmed = IVerge {
profile_dns_settings: [(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: source.clone(),
},
)]
.into(),
enable_dns_settings: Some(true),
dns_override_confirmation: source.clone(),
..IVerge::default()
};
let saved = serde_yaml_ng::to_string(&confirmed)?;
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut restarted));
for (saved, source) in [
(saved, Some("updated".into())),
(
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
source,
),
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
for saved in [
serde_yaml_ng::to_string(&confirmed)?,
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
] {
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
let state = DnsOverrideState::new(
source.clone(),
restarted.enable_dns_settings.unwrap_or(false),
restarted.dns_override_confirmation.clone(),
);
assert!(!state.enabled, "a previous session must not bypass startup protection");
assert!(state.apply_to(&mut restarted));
assert!(!restarted.dns_settings_for("one").enabled);
assert_eq!(restarted.enable_dns_settings, Some(false));
}
Ok(())
}
@@ -238,74 +191,21 @@ mod tests {
enable_dns_settings: Some(true),
..IVerge::default()
};
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
assert!(state.apply_to(&mut verge));
assert!(!verge.dns_settings_for("one").enabled);
assert_eq!(verge.enable_dns_settings, Some(false));
assert!(!state.apply_to(&mut verge));
let disabled = DnsOverrideState::new("one", None, false, None);
let disabled = DnsOverrideState::new(None, false, None);
assert!(!disabled.apply_to(&mut verge));
assert!(!disabled.enabled);
verge.profile_dns_settings.insert(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some("provider-dns".into()),
},
);
verge.enable_dns_settings = Some(true);
verge.dns_override_confirmation = Some("provider-dns".into());
assert!(!state.apply_to(&mut verge));
let settings = verge.dns_settings_for("one");
assert!(settings.enabled);
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
assert!(leaving.apply_to(&mut verge));
assert!(verge.dns_settings_for("one").confirmation.is_none());
}
#[test]
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
let mut verge = IVerge {
enable_dns_settings: Some(true),
..IVerge::default()
};
let first = DnsOverrideState::new("one", None, true, None);
assert!(first.apply_to(&mut verge));
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
assert!(protected.apply_to(&mut verge));
assert!(verge.dns_settings_for("one").enabled);
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("unvisited").enabled);
assert_eq!(verge.enable_dns_settings, Some(true));
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
assert!(restarted.dns_settings_for("one").enabled);
assert!(!restarted.dns_settings_for("two").enabled);
Ok(())
}
#[test]
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
let mut verge = IVerge::default();
for uid in ["one", "two"] {
verge.profile_dns_settings.insert(
uid.into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some(uid.into()),
},
);
}
for uid in ["one", "two", "one"] {
let settings = verge.dns_settings_for(uid);
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut verge));
}
let settings = verge.dns_settings_for("two");
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
assert!(updated.apply_to(&mut verge));
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("two").confirmation.is_none());
assert!(verge.dns_settings_for("one").enabled);
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
assert!(leaving.apply_to(&mut verge));
assert!(verge.dns_override_confirmation.is_none());
}
}
+19 -8
View File
@@ -1,5 +1,4 @@
use super::{Config, IClashTemp, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{
constants::timing,
core::{
@@ -15,9 +14,15 @@ use crate::{
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_yaml_ng::Value;
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
use std::{
collections::HashSet,
net::SocketAddr,
str::FromStr as _,
sync::atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Copy)]
struct MixedPortFallback {
@@ -25,8 +30,9 @@ struct MixedPortFallback {
current: u16,
}
static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
@@ -141,12 +147,17 @@ impl Config {
}
pub(crate) fn block_startup_core(error: &anyhow::Error) {
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string());
}
pub(crate) fn startup_core_block_reason() -> Option<String> {
STARTUP_CORE_BLOCK_REASON.lock().clone()
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
STARTUP_CORE_BLOCK_REASON.lock().clone()
} else {
None
}
}
pub(crate) fn notify_startup_mixed_port_fallback() {
@@ -155,8 +166,8 @@ impl Config {
};
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice(
NoticeStatus::MixedPortFallback,
Handle::notice_message(
"mixed_port::fallback",
format!("{},{}", change.original, change.current),
);
});
@@ -185,7 +196,7 @@ fn report_fallback_error(message: String) {
);
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice(NoticeStatus::MixedPortFallbackError, message);
Handle::notice_message("mixed_port::fallback_error", message);
});
}
-5
View File
@@ -48,9 +48,6 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>,
@@ -220,7 +217,6 @@ impl PrfItem {
desc: Some(desc),
file: Some(file),
url: None,
latency_test_urls: None,
selected: None,
extra: None,
option: Some(PrfOption {
@@ -400,7 +396,6 @@ impl PrfItem {
desc: desc.cloned(),
file: Some(file),
url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None,
extra,
option: Some(PrfOption {
+150 -164
View File
@@ -2,8 +2,6 @@ use super::{
PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::core::notify::{Refresh, announce};
use crate::utils::retry::{RetryError, RetryPolicy, retry, retry_with_state};
use crate::{
core::{handle, tray::Tray},
utils::{
@@ -18,7 +16,6 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::num::NonZeroUsize;
use std::{
collections::{HashMap, HashSet},
path::{Component, Path},
@@ -71,14 +68,8 @@ impl ProfileDeletePlan {
return;
};
for file in self.files {
let path = dir.join(file.as_str());
if let Err(error) = path.remove_if_exists().await {
logging!(
warn,
Type::Config,
"Failed to remove deleted profile file {}: {error:#}",
path.display()
);
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
}
}
}
@@ -248,7 +239,6 @@ impl IProfiles {
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
@@ -604,21 +594,19 @@ fn is_activation_current(generation: u64) -> bool {
}
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
let interval = Duration::from_millis(500);
let attempts =
NonZeroUsize::MIN.saturating_add((MIHOMO_OPERATION_TIMEOUT.as_millis() / interval.as_millis()) as usize + 1);
tokio::time::timeout(
MIHOMO_OPERATION_TIMEOUT,
retry(RetryPolicy::fixed(attempts, interval), |_| async {
handle::Handle::mihomo().get_proxies().await.map_err(|error| {
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {error:#}");
RetryError::Retry(error)
})
}),
)
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
loop {
match handle::Handle::mihomo().get_proxies().await {
Ok(proxies) => return proxies,
Err(err) => {
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err:#}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
})
.await
.context("timed out while waiting for mihomo proxies")?
.map_err(Into::into)
.context("timed out while waiting for mihomo proxies")
}
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
@@ -697,58 +685,51 @@ async fn update_tray_after_activation(generation: u64) {
}
}
async fn persist_selected_update<F, Fut>(supersede: bool, edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<(IProfiles, bool)>> + Send,
{
let changed = Config::profiles().await.with_data_modify(edit).await?;
if changed {
if supersede {
supersede_selected_activation();
}
announce(Refresh::Profiles);
}
Ok(())
}
/// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let recorded = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
}
entry.now = Some(node);
}
entry.now = Some(node);
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
}
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
}
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if recorded {
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
}
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
@@ -758,28 +739,36 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
}
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let cleared = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
if !remove_selected_node(&mut selected, group_name) {
return Ok((profiles, false));
}
let mut selected = item.selected.clone().unwrap_or_default();
if !remove_selected_node(&mut selected, group_name) {
return Ok((profiles, false));
}
item.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
item.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if cleared {
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
}
async fn persist_reconciled_selected(
@@ -792,27 +781,34 @@ async fn persist_reconciled_selected(
return Ok(());
}
let profiles = Config::profiles().await;
let profile_uid = profile_uid.clone();
let original_selected = original_selected.to_vec();
persist_selected_update(false, move |mut profiles| async move {
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
return Ok((profiles, false));
}
let updated = profiles
.with_data_modify(move |mut profiles| async move {
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
return Ok((profiles, false));
}
let profile = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
return Ok((profiles, false));
}
let profile = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
return Ok((profiles, false));
}
profile.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
profile.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if updated {
handle::Handle::refresh_profiles();
}
Ok(())
}
/// Returns recorded selections whose groups have not reached the requested node.
@@ -834,58 +830,48 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
/// Retries selections while provider-backed groups finish loading.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
let attempts = NonZeroUsize::MIN.saturating_add(
(SELECTED_NODES_SETTLE_DEADLINE.as_millis() / SELECTED_NODES_SETTLE_INTERVAL.as_millis()) as usize,
);
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
let _ = retry_with_state(
RetryPolicy::fixed(attempts, SELECTED_NODES_SETTLE_INTERVAL),
(selected, completed, generation, deadline),
|state, _| {
Box::pin(async move {
let (selected, completed, generation, deadline) = state;
if !is_activation_current(*generation) {
return Ok(());
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
return if Instant::now() >= *deadline {
Ok(())
} else {
Err(RetryError::Retry(()))
};
};
if !is_activation_current(*generation) {
return Ok(());
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return Ok(());
}
if Instant::now() >= *deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return Ok(());
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, *generation)
.await
.is_none()
{
return Ok(());
}
if is_activation_current(*generation) {
announce(Refresh::Clash);
}
Err(RetryError::Retry(()))
})
},
)
.await;
loop {
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
if !is_activation_current(generation) {
return;
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
// Unreachable core: the deadline still applies, so this cannot spin forever.
if Instant::now() >= deadline {
return;
}
continue;
};
if !is_activation_current(generation) {
return;
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return;
}
if Instant::now() >= deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return;
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, generation)
.await
.is_none()
{
return;
}
if is_activation_current(generation) {
handle::Handle::refresh_clash();
}
}
}
/// Releases the first-pass waiter even when restoration exits early.
@@ -928,7 +914,7 @@ async fn activate_selected_nodes_worker(
}
if is_activation_current(generation) {
announce(Refresh::Clash);
handle::Handle::refresh_clash();
}
let plan = if needs_confirmation {
@@ -952,7 +938,7 @@ async fn activate_selected_nodes_worker(
return Ok(());
};
if confirmed_activated_count > 0 && is_activation_current(generation) {
announce(Refresh::Clash);
handle::Handle::refresh_clash();
}
confirmed_plan
} else {
@@ -1042,7 +1028,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if selected.is_empty() {
if is_activation_current(generation) {
announce(Refresh::Clash);
handle::Handle::refresh_clash();
}
return Ok(());
}
@@ -1054,7 +1040,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if let Err(err) = result {
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
// The profile itself is already active even if node restoration failed.
announce(Refresh::Clash);
handle::Handle::refresh_clash();
}
update_tray_after_activation(generation).await;
logging!(debug, Type::Config, "activating selected nodes done!");
+1 -1
View File
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
use std::{io::ErrorKind, path::PathBuf};
pub(crate) struct FileSnapshot {
pub(crate) path: PathBuf,
path: PathBuf,
content: Option<Vec<u8>>,
}
+4 -3
View File
@@ -80,11 +80,12 @@ pub struct IVerge {
pub enable_bypass_check: Option<bool>,
/// Initial DNS override preference for profiles without a saved setting.
/// enable dns settings - this controls whether dns_config.yaml is applied
pub enable_dns_settings: Option<bool>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
// Force-enable confirmation is valid only for the current app session.
#[serde(skip)]
pub dns_override_confirmation: Option<String>,
pub use_default_bypass: Option<bool>,
+1 -1
View File
@@ -61,7 +61,7 @@ pub mod files {
}
pub mod tun {
pub const DEFAULT_STACK: &str = "mips";
pub const DEFAULT_STACK: &str = "gvisor";
pub const DNS_HIJACK: &[&str] = &["any:53"];
+3 -2
View File
@@ -4,6 +4,7 @@ use anyhow::Error;
use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
use smartstring::alias::String;
use std::{
@@ -11,7 +12,7 @@ use std::{
env::{consts::OS, temp_dir},
io::Write as _,
path::PathBuf,
sync::{Arc, OnceLock},
sync::Arc,
time::Duration,
};
use tokio::{fs, time::timeout};
@@ -57,7 +58,7 @@ pub struct WebDavClient {
impl WebDavClient {
pub fn global() -> &'static Self {
static WEBDAV_CLIENT: OnceLock<WebDavClient> = OnceLock::new();
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
WEBDAV_CLIENT.get_or_init(|| Self {
config: ArcSwapOption::new(None),
clients: ArcSwap::new(Arc::new(HashMap::new())),
@@ -1,10 +1,6 @@
use super::NoticeStatus;
use crate::{APP_HANDLE, singleton};
use smartstring::alias::String;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::AppHandle;
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
@@ -40,11 +36,19 @@ impl Handle {
}
pub fn refresh_clash() {
super::announce(super::Refresh::Clash);
Self::send_event(FrontendEvent::RefreshClash);
}
pub fn refresh_verge() {
super::announce(super::Refresh::Verge);
Self::send_event(FrontendEvent::RefreshVerge);
}
pub fn refresh_profiles() {
Self::send_event(FrontendEvent::RefreshProfiles);
}
pub fn refresh_proxy_config() {
Self::send_event(FrontendEvent::RefreshProxyConfig);
}
/// Push a Run State snapshot to the frontend.
@@ -76,10 +80,13 @@ impl Handle {
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
}
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
let status_str = status.as_ref();
let msg_str = msg.into();
Self::send_event(FrontendEvent::NoticeMessage {
status,
message: message.into(),
status: status_str,
message: msg_str,
});
}
@@ -95,7 +102,7 @@ impl Handle {
self.is_exiting.load(Ordering::Acquire)
}
pub(super) fn send_event(event: FrontendEvent) {
fn send_event(event: FrontendEvent) {
let handle = Self::global();
if handle.is_exiting() {
return;
+4 -4
View File
@@ -1,9 +1,8 @@
use crate::core::notify::NoticeStatus;
use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::notification::{NotificationEvent, notify_event};
use crate::utils::window_manager::WindowManager;
use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_mode, feat};
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
use anyhow::{Result, bail};
use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging};
@@ -178,7 +177,7 @@ impl Hotkey {
"Hotkey profile reactivation failed validation: {}",
message.as_str()
);
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
handle::Handle::notice_message("reactivate_profiles::error", message);
}
Err(err) => {
logging!(
@@ -187,7 +186,7 @@ impl Hotkey {
"Failed to reactivate subscriptions via hotkey: {}",
err
);
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
handle::Handle::notice_message("reactivate_profiles::error", err.to_string());
}
});
}
@@ -201,6 +200,7 @@ impl Hotkey {
HotkeyFunction::Hide => {
AsyncHandler::spawn(async move || {
feat::hide().await;
notify_event(NotificationEvent::AppHidden).await;
});
}
}
+9 -2
View File
@@ -271,7 +271,10 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
}
let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
let normalized = bind_address
.strip_prefix('[')
.and_then(|address| address.strip_suffix(']'))
.unwrap_or(bind_address);
Ok(vec![
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
])
@@ -295,7 +298,11 @@ fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
if port == 0 {
bail!("listener port must be between 1 and 65535");
}
let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
let host = host
.trim()
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or_else(|| host.trim());
if host.eq_ignore_ascii_case("localhost") {
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
}
+5 -99
View File
@@ -1,5 +1,4 @@
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::core::notify::NoticeStatus;
use crate::core::service::StageRequest;
use crate::{
config::{Config, IProfiles, runtime::IRuntime},
@@ -19,7 +18,6 @@ use smartstring::alias::String;
use std::{
collections::HashSet,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use tauri_plugin_mihomo::Error as MihomoError;
@@ -131,31 +129,7 @@ where
}
impl CoreManager {
pub(crate) fn claim_config_update(
&self,
_config_write: &tokio::sync::MutexGuard<'_, ()>,
) -> Result<ConfigUpdateGuard<'_>> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
}
Ok(ConfigUpdateGuard(self))
}
pub(crate) async fn update_config_in_patch(&self, _update: &ConfigUpdateGuard<'_>) -> Result<()> {
if handle::Handle::global().is_exiting() {
anyhow::bail!("application is exiting");
}
self.set_last_update(Instant::now());
Config::generate().await?;
let outcome = self.validate_and_apply_draft().await?;
if outcome.is_valid() {
Ok(())
} else {
Err(anyhow!("{outcome}"))
}
}
pub async fn use_default_config(&self, status: NoticeStatus, message: &str) -> Result<()> {
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
use crate::constants::files::RUNTIME_CONFIG;
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
@@ -171,7 +145,7 @@ impl CoreManager {
});
help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?;
handle::Handle::notice(status, message);
handle::Handle::notice_message(error_key, error_msg);
Ok(())
}
@@ -260,7 +234,6 @@ impl CoreManager {
let outcome = self.perform_config_update(Some(profiles)).await?;
if outcome.is_valid() {
crate::config::profiles::restore_selected_nodes().await;
handle::Handle::refresh_clash();
Ok(())
} else {
Err(anyhow!("failed to restore previous Core configuration: {outcome}"))
@@ -324,27 +297,8 @@ impl CoreManager {
self.validate_and_apply(transaction).await
}
/// Commits the applied runtime even if restoring the system proxy subsequently fails.
/// Validates and applies the caller's transaction, committing only on success.
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
let runtime = Config::runtime().await;
let original_runtime = runtime.data_arc();
let outcome = match self.validate_and_apply_draft().await {
Ok(outcome) => outcome,
Err(error) => {
transaction.rollback();
if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) {
handle::Handle::refresh_clash();
}
return Err(error);
}
};
if outcome.is_valid() {
transaction.commit();
}
Ok(outcome)
}
async fn validate_and_apply_draft(&self) -> Result<ValidationOutcome> {
// One serialization feeds check and run files; the core never applies unvalidated bytes.
let yaml = Config::runtime_config_yaml().await?;
let outcome = CoreConfigValidator::global()
@@ -355,34 +309,11 @@ impl CoreManager {
}
let run_path = Config::write_runtime_file(&yaml).await?;
let previous = self.current_core_readiness_generation();
let result = self.apply_config(run_path).await;
self.retain_applied_runtime(&Config::runtime().await, previous, &result);
result?;
// Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it.
#[cfg(target_os = "macos")]
{
let _lifecycle = self.lifecycle_lock.lock().await;
crate::utils::resolve::dns::sync_public_dns().await;
}
self.apply_config(run_path).await?;
transaction.commit();
Ok(ValidationOutcome::Valid)
}
fn retain_applied_runtime(
&self,
runtime: &clash_verge_draft::Draft<IRuntime>,
previous: Option<u64>,
result: &Result<()>,
) {
if result.is_ok()
|| self
.current_core_readiness_generation()
.is_some_and(|current| Some(current) != previous)
{
runtime.apply();
}
}
/// Applies a generated configuration through the active core owner.
async fn apply_config(&self, path: PathBuf) -> Result<()> {
if matches!(*self.get_running_mode(), RunningMode::Service) {
@@ -474,31 +405,6 @@ mod tests {
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
use std::{cell::Cell, time::Duration};
#[test]
fn a_ready_replacement_keeps_the_applied_runtime_when_proxy_restore_fails() -> anyhow::Result<()> {
use crate::{config::runtime::IRuntime, core::CoreManager};
use clash_verge_draft::{Draft, DraftTransaction};
for replaced in [false, true] {
let manager = CoreManager::default();
manager.mark_core_ready();
let previous = manager.current_core_readiness_generation();
let runtime = Draft::new(IRuntime::default());
let transaction = DraftTransaction::begin(vec![&runtime])?;
runtime.edit_draft(|draft| {
draft.exists_keys.insert("replacement".into());
});
if replaced {
manager.mark_core_ready();
}
let result = Err(anyhow::anyhow!("system proxy restore failed"));
manager.retain_applied_runtime(&runtime, previous, &result);
transaction.rollback();
assert_eq!(runtime.data_arc().exists_keys.contains("replacement"), replaced);
}
Ok(())
}
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
fn staged() -> StageAttempt {
+72 -287
View File
@@ -62,11 +62,7 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_)
| ServiceStatus::SidecarAllowed
) | (
RunningMode::Sidecar,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
)
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
)
}
@@ -189,60 +185,18 @@ where
apply_proxy().await
}
#[cfg(any(target_os = "windows", test))]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
record_unavailable: impl FnOnce(std::string::String),
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Check: FnOnce() -> CheckFuture,
CheckFuture: std::future::Future<Output = Result<()>>,
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = Result<()>>,
{
let Err(error) = start_service().await else {
return Ok(());
};
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
let location_refused = refusal.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if refusal.is_some() && !location_refused {
return Err(error);
}
// A lost start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}"));
return Err(error);
}
logging!(
warn,
Type::Core,
"Service could not start the core; falling back to Sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
) -> Result<()>
where
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce() -> TerminateFuture,
TerminateFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce(),
{
clear_proxy().await?;
terminate_sidecar().await
terminate_sidecar();
Ok(())
}
async fn run_service_config_replacement_transition<
@@ -334,18 +288,6 @@ enum HandoffOutcome {
Failed,
}
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
let error = match service_rejection {
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
None => error,
};
// Proxy failures have their own pending state and recovery dialog.
if SysproxyFailure::from_chain(&error).is_none() {
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
}
error
}
impl CoreManager {
async fn rollback_failed_start(&self) {
proxy_control::stop_guard().await;
@@ -376,12 +318,9 @@ impl CoreManager {
}
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
pub async fn continue_with_sidecar(&self) -> Result<()> {
if !self.try_start_config_update() {
return Err(report_sidecar_failure(
anyhow::anyhow!("configuration update is already running"),
service_rejection,
));
anyhow::bail!("configuration update is already running");
}
defer! {
self.finish_config_update();
@@ -390,68 +329,55 @@ impl CoreManager {
// draft without claiming it, so without this it can commit another transaction's staged patch.
let config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await;
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
let result = async {
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
SERVICE_MANAGER.allow_sidecar_for_session()?;
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)?;
}
if !matches!(status, ServiceStatus::SidecarAllowed) {
SERVICE_MANAGER.allow_sidecar_for_session()?;
}
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
Config::generate().await
}
.await;
drop(config_write);
if let Err(error) = prepared {
SERVICE_MANAGER.withdraw_sidecar_allowance();
return Err(error);
}
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
proxy_control::stop_guard().await;
let result = async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
.await;
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
Config::generate().await
}
.await;
result.map_err(|error| report_sidecar_failure(error, service_rejection))
drop(config_write);
if let Err(error) = prepared {
SERVICE_MANAGER.withdraw_sidecar_allowance();
return Err(error);
}
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
proxy_control::stop_guard().await;
let result = async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
.await;
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
}
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
@@ -517,13 +443,7 @@ impl CoreManager {
}
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
let result =
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await;
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
}
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
@@ -552,8 +472,6 @@ impl CoreManager {
.ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
})?;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
// At login the app usually beats the network. Leave the write to the network watcher
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
@@ -602,7 +520,6 @@ impl CoreManager {
}
// tell the window when a background apply lands
Handle::refresh_verge();
crate::core::notification::retire_sidecar_failure();
Ok(())
}
@@ -625,10 +542,6 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
}
if Handle::global().is_exiting() {
@@ -637,27 +550,7 @@ impl CoreManager {
}
let result = match startup {
StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(),
|| async {
clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)
},
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Service => self.start_core_by_service().await,
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()),
};
@@ -665,8 +558,6 @@ impl CoreManager {
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
return result;
}
@@ -678,53 +569,6 @@ impl CoreManager {
result
}
#[cfg(target_os = "windows")]
async fn try_sidecar_after_service_unavailable(&self) -> Result<()> {
use crate::core::runstate::{RUN_STATE, ServiceHealth};
let state = RUN_STATE.state();
if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() {
return Ok(());
}
let reason = match state.health {
ServiceHealth::Unavailable(reason) => reason,
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()),
};
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)
{
logging!(
warn,
Type::Core,
"Service unavailable ({reason}); Sidecar fallback withheld: {error:#}"
);
return Ok(());
}
self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await
}
#[cfg(target_os = "windows")]
async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> {
use crate::core::runstate::RUN_STATE;
if Handle::global().is_exiting() {
return Err(error.context("application exited before Sidecar fallback"));
}
let reason = format!("{error:#}");
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
let result = self.start_core_by_sidecar().await;
if result.is_err() {
SERVICE_MANAGER.withdraw_sidecar_allowance();
self.rollback_failed_start().await;
} else {
crate::core::service::notify_service_fallback(&reason);
}
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
pub async fn stop_core(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
@@ -758,17 +602,14 @@ impl CoreManager {
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs();
let result = match *self.get_running_mode() {
match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::Sidecar => {
self.stop_core_by_sidecar_unprepared();
Ok(())
}
RunningMode::NotRunning => Ok(()),
};
// A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS.
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
#[tracing::instrument(skip_all, level = "info")]
@@ -850,12 +691,7 @@ impl CoreManager {
return;
}
let state = crate::core::runstate::RUN_STATE.state();
// Waiting cannot bring up a service nothing will start.
if state.service_usable()
|| matches!(&state.health, crate::core::runstate::ServiceHealth::Unavailable(reason)
if crate::core::service::is_not_auto_started(reason))
{
if crate::core::runstate::RUN_STATE.state().service_usable() {
return;
}
@@ -893,34 +729,29 @@ impl CoreManager {
AsyncHandler::spawn(|| async move {
let manager = Self::global();
let started = Instant::now();
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let attempts = std::num::NonZeroUsize::MIN.saturating_add(
(timing::SERVICE_HANDOFF_WINDOW.as_millis() / timing::SERVICE_HANDOFF_INTERVAL.as_millis()) as usize,
);
let _ = retry(RetryPolicy::fixed(attempts, std::time::Duration::ZERO), |_| async {
loop {
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
logging!(
info,
Type::Core,
"service handoff window elapsed; staying in sidecar mode"
);
return Ok(());
break;
}
// The window is checked before waiting, including the final handoff attempt.
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
return Ok(());
break;
}
match manager.try_handoff_sidecar_to_service().await {
HandoffOutcome::Done => Ok(()),
HandoffOutcome::Done => break,
HandoffOutcome::Failed => {
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
Ok(())
break;
}
HandoffOutcome::NotReady => Err(RetryError::Retry(())),
HandoffOutcome::NotReady => {}
}
})
.await;
}
manager.handoff_watcher_running.store(false, Ordering::Release);
});
}
@@ -1277,10 +1108,9 @@ mod tests {
let result = run_sidecar_termination_transition(
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|| async {
|| {
sidecar_alive.store(false, Ordering::Release);
calls.lock().push("handoff-sidecar-stop");
Ok(())
},
)
.await;
@@ -1538,18 +1368,12 @@ mod tests {
ServiceStatus::NeedsReinstall,
ServiceStatus::InstallRequired,
ServiceStatus::Unavailable("offline".into()),
ServiceStatus::SidecarAllowed,
];
for status in &allowed_statuses {
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
if !matches!(
status,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
) {
if !matches!(status, ServiceStatus::InstallRequired) {
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
} else {
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
}
}
@@ -1559,52 +1383,13 @@ mod tests {
ServiceStatus::UninstallRequired,
ServiceStatus::ReinstallRequired,
ServiceStatus::ForceReinstallRequired,
ServiceStatus::SidecarAllowed,
];
for status in &rejected_statuses {
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
}
}
#[tokio::test]
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
for idle in [false, true] {
let store = RunStateStore::new(FakeEnv::new());
store.observe(ServiceHealth::Ready);
let started = AtomicBool::new(false);
let result = super::run_service_start_with_sidecar_fallback(
|| async {
Err(crate::core::service::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
core_path: "verge-mihomo.exe".into(),
message: "no administrator-approved copy is installed".into(),
}
.into())
},
|| async move {
anyhow::ensure!(idle, "another core is running");
Ok(())
},
|_| async {
started.store(true, Ordering::SeqCst);
Ok(())
},
|reason| store.observe(ServiceHealth::Unavailable(reason)),
)
.await;
assert_eq!(started.load(Ordering::SeqCst), idle);
assert_eq!(result.is_ok(), idle);
assert!(!store.state().sidecar_allowed);
if !idle {
assert!(store.state().service_needs_attention());
assert!(!store.state().service_usable());
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
}
}
}
#[test]
fn failed_service_install_can_continue_with_an_existing_sidecar() {
assert!(can_allow_sidecar_for_session(
+40 -99
View File
@@ -1,15 +1,15 @@
mod config;
pub(crate) use config::ConfigUpdateGuard;
mod lifecycle;
mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging};
use once_cell::sync::Lazy;
use std::{
fmt,
sync::{
Arc, LazyLock,
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Instant,
@@ -21,7 +21,7 @@ use crate::singleton;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
pub(crate) static CLASH_LOGGER: LazyLock<Arc<LogRing>> = LazyLock::new(|| Arc::new(LogRing::new()));
pub(crate) static CLASH_LOGGER: Lazy<Arc<LogRing>> = Lazy::new(|| Arc::new(LogRing::new()));
tokio::task_local! {
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
@@ -74,14 +74,6 @@ impl fmt::Display for RunningMode {
}
}
/// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure {
StartFailed(String),
ServiceCoreStopped(String),
}
#[derive(Debug)]
pub struct CoreManager {
/// The Run State this manager reports transitions to.
@@ -95,8 +87,6 @@ pub struct CoreManager {
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
@@ -124,8 +114,6 @@ impl Default for CoreManager {
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
core_readiness_state: AtomicU64::new(0),
startup_error: parking_lot::Mutex::new(None),
sidecar_exit: tokio::sync::Mutex::new(None),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
handoff_watcher_running: AtomicBool::new(false),
@@ -151,17 +139,6 @@ impl CoreManager {
self.run_state.mode_arc()
}
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
let mut startup_error = self.startup_error.lock();
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
*startup_error = Some(failure);
}
}
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
self.startup_error.lock().clone()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
self.state
.load()
@@ -187,9 +164,7 @@ impl CoreManager {
if previous != mode {
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
}
let mut startup_error = self.startup_error.lock();
self.run_state.core_started(mode);
*startup_error = None;
}
/// The Core is no longer running, for any reason.
@@ -237,14 +212,14 @@ impl CoreManager {
}
}
pub(crate) fn current_core_readiness_generation(&self) -> Option<u64> {
fn current_core_readiness_generation(&self) -> Option<u64> {
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
}
pub(crate) fn invalidate_core_readiness(&self) {
let _ = self
.core_readiness_state
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
active_core_readiness_generation(current).map(inactive_core_readiness_state)
});
}
@@ -284,83 +259,49 @@ impl CoreManager {
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
}
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(MAX_PORT_FALLBACK_RETRIES),
std::time::Duration::ZERO,
),
|retries| async move {
match self.start_core().await {
Ok(()) => {
let mut retries = 0;
loop {
match self.start_core().await {
Ok(()) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning));
}
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::config::Config::notify_startup_mixed_port_fallback();
Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning))
return Err(start_error);
}
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
match crate::config::Config::resolve_startup_mixed_port().await {
Ok(true) => {
retries += 1;
tracing::Span::current().record("retries", retries);
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
retries,
MAX_PORT_FALLBACK_RETRIES
);
}
Ok(false) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(RetryError::Stop(start_error));
return Err(start_error);
}
match crate::config::Config::resolve_startup_mixed_port().await {
Ok(true) => {
let retries = retries + 1;
tracing::Span::current().record("retries", retries);
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
retries,
MAX_PORT_FALLBACK_RETRIES
);
Err(RetryError::Retry(start_error))
}
Ok(false) => {
crate::config::Config::notify_startup_mixed_port_fallback();
Err(RetryError::Stop(start_error))
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
Err(RetryError::Stop(start_error.context(format!(
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
))))
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
return Err(start_error.context(format!(
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
)));
}
}
Err(error) => {
crate::config::Config::notify_startup_mixed_port_fallback();
Err(RetryError::Stop(error))
}
}
},
)
.await
Err(error) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(error);
}
}
}
}
}
singleton!(CoreManager, CORE_MANAGER);
#[cfg(test)]
mod startup_error_tests {
use super::{CoreFailure, CoreManager, RunningMode};
#[test]
fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() {
for mode in [RunningMode::Sidecar, RunningMode::Service] {
let manager = CoreManager::isolated();
manager.record_startup_error(CoreFailure::StartFailed("startup failed".into()));
manager.core_started(mode);
manager.core_stopped();
assert_eq!(manager.get_startup_error(), None);
}
}
#[test]
fn unresolved_startup_error_survives_repeated_reads() {
let manager = CoreManager::isolated();
let failure = CoreFailure::StartFailed("startup failed".into());
manager.record_startup_error(failure.clone());
for _ in 0..2 {
assert_eq!(manager.get_startup_error().as_ref(), Some(&failure));
}
}
}
+75 -171
View File
@@ -1,7 +1,6 @@
#[cfg(test)]
use super::claim_core_readiness_generation;
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::utils::retry::{RetryError, RetryPolicy, retry};
use crate::{
AsyncHandler,
config::Config,
@@ -12,76 +11,13 @@ use crate::{
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use log::Level;
use std::num::NonZeroUsize;
use scopeguard::defer;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
let detail = error.to_string();
if detail.starts_with("process verge-mihomo") && detail.ends_with("; refusing a second core") {
error.context(clash_verge_i18n::t!("core.alreadyRunning").into_owned())
} else {
error
}
}
#[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value};
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
let tun = config
.entry(Value::from("tun"))
.or_insert_with(|| Value::Mapping(Mapping::new()));
if !tun.is_mapping() {
*tun = Value::Mapping(Mapping::new());
}
tun.as_mapping_mut()
.context("invalid Sidecar TUN configuration")?
.insert(Value::from("enable"), Value::Bool(false));
Ok(serde_yaml_ng::to_string(&config)?)
}
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
#[cfg(target_os = "windows")]
async fn retry_service_start<Start, StartFuture>(
attempts: usize,
retry_delay: std::time::Duration,
mut start: Start,
) -> Result<()>
where
Start: FnMut() -> StartFuture + Send,
StartFuture: std::future::Future<Output = Result<()>> + Send,
{
let attempts = NonZeroUsize::new(attempts).ok_or_else(|| anyhow::anyhow!("service start failed"))?;
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
// The Windows Service IPC future would otherwise inflate every enclosing lifecycle future.
let future = Box::pin(start());
async move {
future.await.map_err(|error| {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {error:#}",
attempt + 1,
attempts
);
if error
.downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
{
RetryError::Stop(error)
} else {
RetryError::Retry(error)
}
})
}
})
.await
}
impl CoreManager {
/// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
@@ -105,16 +41,14 @@ async fn poll_sidecar_readiness<F, Fut>(
mut probe: F,
) -> Result<()>
where
F: FnMut() -> Fut + Send,
Fut: std::future::Future<Output = Result<()>> + Send,
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let attempts = NonZeroUsize::new(max_attempts)
.context("sidecar readiness was configured with no attempts")
.context("Mihomo API did not become ready")?;
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
let future = probe();
async move {
future.await.map_err(|error| {
let mut last_error = None;
for attempt in 0..max_attempts {
match probe().await {
Ok(()) => return Ok(()),
Err(error) => {
logging!(
debug,
Type::Core,
@@ -122,12 +56,16 @@ where
attempt + 1,
max_attempts
);
RetryError::Retry(error)
})
last_error = Some(error);
}
}
})
.await
.context("Mihomo API did not become ready")
if attempt + 1 < max_attempts {
tokio::time::sleep(retry_delay).await;
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
.context("Mihomo API did not become ready"))
}
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
@@ -160,26 +98,12 @@ impl CoreManager {
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
self.wait_for_sidecar_exit().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
.await
.inspect_err(service::record_residual_service)
.map_err(explain_core_occupancy)?;
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
#[cfg(target_os = "windows")]
let config_file = if crate::core::runstate::RUN_STATE.state().is_admin {
Config::generate_file().await?
} else {
// Reconciliation persists the preference later; the first spawn must already have TUN off.
let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?;
Config::write_runtime_file(&yaml).await?
};
#[cfg(not(target_os = "windows"))]
let config_file = Config::generate_file().await?;
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
@@ -217,8 +141,6 @@ impl CoreManager {
config_dir.display()
)
})?;
let (terminated, termination) = tokio::sync::oneshot::channel();
*self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination));
#[cfg(target_os = "windows")]
let job = {
match create_and_assign_sidecar_job(child.pid()) {
@@ -248,40 +170,6 @@ impl CoreManager {
let pid = child.pid();
tracing::Span::current().record("pid", pid);
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
// single event, so an unread channel stalls its reader threads and the core blocks on a
// full stdout pipe before it ever creates the API socket.
let core_readiness_generation = self.mark_core_ready();
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy_owned(line);
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let _ = terminated.send(());
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
@@ -305,15 +193,48 @@ impl CoreManager {
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
Ok(())
}
/// Terminates the sidecar after its caller has successfully cleared the
/// system proxy.
pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> {
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
defer! {
self.core_stopped();
}
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
@@ -329,22 +250,6 @@ impl CoreManager {
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
}
}
let result = self.wait_for_sidecar_exit().await;
self.core_stopped();
result
}
async fn wait_for_sidecar_exit(&self) -> Result<()> {
let mut exit = self.sidecar_exit.lock().await;
if let Some(task) = exit.as_mut() {
tokio::time::timeout(std::time::Duration::from_secs(5), task)
.await
.context("Sidecar has not exited; core handoff is blocked")?
.context("Sidecar exit monitoring failed")?;
exit.take();
}
drop(exit);
Ok(())
}
pub(super) async fn start_core_by_service(&self) -> Result<()> {
@@ -364,15 +269,30 @@ impl CoreManager {
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
service::run_core_by_service(config_file)
})
.await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
Ok(())
let mut last_err = None;
for attempt in 0..timing::SERVICE_START_RETRIES {
match service::run_core_by_service(config_file).await {
Ok(()) => {
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
return Ok(());
}
Err(e) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {e:#}",
attempt + 1,
timing::SERVICE_START_RETRIES
);
last_err = Some(e);
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
}
#[cfg(not(target_os = "windows"))]
@@ -403,8 +323,6 @@ impl CoreManager {
self.set_job_handle(None);
proxy_control::stop_guard().await;
self.core_stopped();
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
}
}
@@ -521,20 +439,6 @@ mod readiness_tests {
time::Duration,
};
#[test]
fn core_occupancy_explanation_preserves_the_original_error() {
for detail in [
"process verge-mihomo remains after IPC failure; refusing a second core",
"process verge-mihomo-alpha.exe (PID 123) is still running; refusing a second core",
] {
let error = super::explain_core_occupancy(anyhow::anyhow!(detail));
assert_eq!(error.to_string(), clash_verge_i18n::t!("core.alreadyRunning"));
assert_eq!(error.root_cause().to_string(), detail);
}
let error = super::explain_core_occupancy(anyhow::anyhow!("could not inspect remaining cores"));
assert_eq!(error.to_string(), "could not inspect remaining cores");
}
#[tokio::test]
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
let attempts = Arc::new(AtomicUsize::new(0));
+3 -5
View File
@@ -1,19 +1,18 @@
pub mod auto_backup;
pub mod autostart;
pub mod backup;
pub mod handle;
pub mod hotkey;
pub mod lightweight;
pub mod listener;
pub mod logger;
pub mod manager;
#[cfg(target_os = "macos")]
pub mod network_watch;
pub mod notify;
pub mod notification;
pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
pub mod runstate;
pub(crate) mod runtime_bundle;
mod runtime_bundle;
pub mod service;
pub mod sysopt;
pub mod timer;
@@ -23,4 +22,3 @@ pub mod validate;
pub mod win_uwp;
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
pub use notify::{handle, notification};
@@ -1,22 +1,24 @@
use super::NoticeStatus;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_json::json;
use smartstring::alias::String;
use std::sync::Arc;
use std::sync::LazyLock;
use std::{collections::HashMap, future::Future};
use std::{
collections::HashMap,
future::Future,
sync::atomic::{AtomicU64, Ordering},
};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
#[derive(Debug, Clone)]
#[derive(Debug)]
pub enum FrontendEvent<'a> {
RefreshClash,
RefreshVerge,
RefreshProfiles,
RefreshProxyConfig,
NoticeMessage {
status: NoticeStatus,
message: Arc<str>,
status: &'a str,
message: String,
},
ProfileChanged {
current_profile_id: &'a String,
@@ -87,23 +89,16 @@ pub struct PendingFailure {
/// Non-destructive pending state, indexed by stable code.
#[derive(Debug, Default)]
struct FailureTable {
entries: Mutex<FailureEntries>,
}
#[derive(Debug, Default)]
struct FailureEntries {
map: HashMap<String, PendingFailure>,
sequence: u64,
entries: Mutex<HashMap<String, PendingFailure>>,
sequence: AtomicU64,
}
impl FailureTable {
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
// Sequence assignment and replacement must share one ordering lock.
let mut entries = self.entries.lock();
entries.sequence = entries.sequence.wrapping_add(1);
// Preserve an unanswered request over a later restore.
let operation = entries
.map
.get(code)
.filter(|existing| existing.operation.outranks(operation))
.map_or(operation, |existing| existing.operation);
@@ -111,13 +106,13 @@ impl FailureTable {
code: code.into(),
detail,
operation,
sequence: entries.sequence,
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
};
entries.map.insert(code.into(), failure);
entries.insert(code.into(), failure);
}
fn snapshot(&self) -> Vec<PendingFailure> {
let mut failures: Vec<PendingFailure> = self.entries.lock().map.values().cloned().collect();
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
failures.sort_by_key(|failure| failure.sequence);
failures
}
@@ -125,21 +120,17 @@ impl FailureTable {
/// Return whether a guard failure was retired.
fn retire_guard(&self) -> bool {
let mut entries = self.entries.lock();
let before = entries.map.len();
entries
.map
.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
before != entries.map.len()
let before = entries.len();
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
before != entries.len()
}
/// Return whether any proxy failure was retired.
fn retire_system_proxy(&self, asked: FailedOperation) -> bool {
let mut entries = self.entries.lock();
let before = entries.map.len();
entries
.map
.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
before != entries.map.len()
let before = entries.len();
entries.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
before != entries.len()
}
}
@@ -238,47 +229,7 @@ pub fn what_was_asked() -> FailedOperation {
.unwrap_or(FailedOperation::SystemProxyRestore)
}
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default);
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SidecarFailureSnapshot {
pub revision: u64,
pub detail: Option<String>,
}
impl SidecarFailureSnapshot {
fn record(&mut self, detail: String) {
self.revision += 1;
self.detail = Some(detail);
}
fn recover(&mut self) -> bool {
if self.detail.take().is_none() {
return false;
}
self.revision += 1;
true
}
}
static SIDECAR_FAILURE: LazyLock<Mutex<SidecarFailureSnapshot>> = LazyLock::new(Mutex::default);
pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot {
SIDECAR_FAILURE.lock().clone()
}
pub fn record_sidecar_failure(detail: String) {
SIDECAR_FAILURE.lock().record(detail);
notify_pending_failures_changed();
}
pub fn retire_sidecar_failure() {
let changed = SIDECAR_FAILURE.lock().recover();
if changed {
notify_pending_failures_changed();
}
}
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
PENDING_FAILURES.record(operation, code, detail.into());
@@ -286,7 +237,7 @@ pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<
}
pub fn has_pending_failure(code: &str) -> bool {
PENDING_FAILURES.entries.lock().map.contains_key(code)
PENDING_FAILURES.entries.lock().contains_key(code)
}
/// Return unresolved failures oldest first without clearing them.
@@ -332,10 +283,9 @@ impl NotificationSystem {
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
FrontendEvent::NoticeMessage { status, message } => (
"verge://notice-message",
serde_json::to_value((status, message.as_ref())),
),
FrontendEvent::NoticeMessage { status, message } => {
("verge://notice-message", serde_json::to_value((status, message)))
}
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
@@ -363,106 +313,3 @@ impl NotificationSystem {
}
}
}
#[cfg(test)]
mod wire_tests {
use super::*;
#[test]
fn frontend_event_wire_contract() -> Result<(), serde_json::Error> {
let uid = String::from("profile");
for (event, name, payload) in super::sample_frontend_events(&uid) {
let (actual_name, actual_payload) = NotificationSystem::serialize_event(event);
assert_eq!(actual_name, name);
assert_eq!(actual_payload?, payload);
}
Ok(())
}
}
/// One sample per `FrontendEvent` variant, paired with its wire name and payload.
/// Shared by the wire golden test and the contract export so the two cannot drift.
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
vec![
(
FrontendEvent::RefreshClash,
"verge://refresh-clash-config",
json!("yes"),
),
(
FrontendEvent::RefreshVerge,
"verge://refresh-verge-config",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
(
FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config",
json!(null),
),
(
FrontendEvent::NoticeMessage {
status: NoticeStatus::Info,
message: Arc::from("ok"),
},
"verge://notice-message",
json!(["info", "ok"]),
),
(
FrontendEvent::TimerUpdated { profile_index: uid },
"verge://timer-updated",
json!("profile"),
),
(
FrontendEvent::RunStateChanged {
state: json!({"running": true}),
},
"verge://run-state-changed",
json!({"running": true}),
),
(
FrontendEvent::PendingFailuresChanged,
"verge://pending-failures-changed",
json!(null),
),
(
FrontendEvent::ProfileChanged {
current_profile_id: uid,
},
"profile-changed",
json!("profile"),
),
(
FrontendEvent::ProfileUpdateStarted { uid },
"profile-update-started",
json!({"uid": "profile"}),
),
(
FrontendEvent::ProfileUpdateCompleted { uid },
"profile-update-completed",
json!({"uid": "profile"}),
),
]
}
/// Machine-readable form of the frontend wire contract: every event name with a
/// serialized sample payload, plus every notice status string. The sample
/// payload's JSON shape (string / null / array / object) is what the frontend
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in
/// linux-only window event, not part of this contract.
pub fn frontend_wire_contract() -> serde_json::Value {
let uid = String::from("sample");
let events: Vec<_> = sample_frontend_events(&uid)
.into_iter()
.map(|(event, _, _)| {
let (name, payload) = NotificationSystem::serialize_event(event);
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) })
})
.collect();
let statuses: Vec<_> = NoticeStatus::ALL
.iter()
// A unit variant with a string rename cannot fail to serialize.
.filter_map(|status| serde_json::to_value(status).ok())
.collect();
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
}
-92
View File
@@ -1,92 +0,0 @@
use super::{handle::Handle, notification::FrontendEvent};
use parking_lot::Mutex;
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refresh {
Clash,
Verge,
Profiles,
Proxies,
}
const REFRESH_WINDOW: Duration = Duration::from_millis(20);
static BATCH: RefreshBatch = RefreshBatch(Mutex::new(Vec::new()));
#[derive(Default)]
struct RefreshBatch(Mutex<Vec<Refresh>>);
impl RefreshBatch {
fn enqueue(&self, refresh: Refresh) -> bool {
let mut pending = self.0.lock();
let start_window = pending.is_empty();
if !pending.contains(&refresh) {
pending.push(refresh);
}
start_window
}
async fn flush(&self, emit: impl Fn(Refresh)) {
// A fixed window bounds latency even under a continuous stream of refreshes.
tokio::time::sleep(REFRESH_WINDOW).await;
let refreshes = std::mem::take(&mut *self.0.lock());
for refresh in refreshes {
emit(refresh);
}
}
}
pub fn announce(refresh: Refresh) {
if BATCH.enqueue(refresh) {
crate::AsyncHandler::spawn(|| BATCH.flush(emit));
}
}
fn emit(refresh: Refresh) {
Handle::send_event(match refresh {
Refresh::Clash => FrontendEvent::RefreshClash,
Refresh::Verge => FrontendEvent::RefreshVerge,
Refresh::Profiles => FrontendEvent::RefreshProfiles,
Refresh::Proxies => FrontendEvent::RefreshProxyConfig,
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> anyhow::Result<()> {
let batch = RefreshBatch::default();
let delivered = Mutex::new(Vec::new());
assert!(batch.enqueue(Refresh::Clash));
for refresh in [Refresh::Verge, Refresh::Clash, Refresh::Profiles, Refresh::Proxies] {
assert!(!batch.enqueue(refresh));
}
tokio::time::timeout(Duration::from_secs(1), async {
batch
.flush(|refresh| {
delivered.lock().push(refresh);
if refresh == Refresh::Clash {
assert!(batch.enqueue(Refresh::Clash));
assert!(!batch.enqueue(Refresh::Clash));
}
})
.await;
batch.flush(|refresh| delivered.lock().push(refresh)).await;
})
.await?;
assert_eq!(
*delivered.lock(),
[
Refresh::Clash,
Refresh::Verge,
Refresh::Profiles,
Refresh::Proxies,
Refresh::Clash
]
);
assert!(batch.enqueue(Refresh::Verge));
Ok(())
}
}
-174
View File
@@ -1,174 +0,0 @@
mod announce;
pub use announce::{Refresh, announce};
pub mod desktop;
pub mod handle;
pub mod notification;
pub use notification::frontend_wire_contract;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum NoticeStatus {
#[serde(rename = "info")]
Info,
#[serde(rename = "import_sub_url::ok")]
ImportSubUrlOk,
#[serde(rename = "import_sub_url::error")]
ImportSubUrlError,
#[serde(rename = "set_config::error")]
SetConfigError,
#[serde(rename = "core_start::error")]
CoreStartError,
#[serde(rename = "service_core::repair_required")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
ServiceCoreRepairRequired,
#[serde(rename = "service_core::app_data_not_owned")]
ServiceCoreAppDataNotOwned,
#[serde(rename = "service_core::sidecar_fallback")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
ServiceCoreSidecarFallback,
#[serde(rename = "dns_override::auto_disabled")]
DnsOverrideAutoDisabled,
#[serde(rename = "enhance::discarded_keys")]
EnhanceDiscardedKeys,
#[serde(rename = "tun_mode::auto_disabled")]
TunModeAutoDisabled,
#[serde(rename = "tun_mode::auto_disable_failed")]
TunModeAutoDisableFailed,
#[serde(rename = "app_restart::core_stop_failed")]
AppRestartCoreStopFailed,
#[serde(rename = "app_quit::core_stop_failed")]
AppQuitCoreStopFailed,
#[serde(rename = "update_with_clash_proxy")]
UpdateWithClashProxy,
#[serde(rename = "update_failed_even_with_clash")]
UpdateFailedEvenWithClash,
#[serde(rename = "reactivate_profiles::error")]
ReactivateProfilesError,
#[serde(rename = "update_failed")]
UpdateFailed,
#[serde(rename = "config_validate::boot_error")]
ConfigValidateBootError,
#[serde(rename = "config_validate::error")]
ConfigValidateError,
#[serde(rename = "config_validate::process_terminated")]
ConfigValidateProcessTerminated,
#[serde(rename = "config_validate::script_error")]
ConfigValidateScriptError,
#[serde(rename = "config_validate::script_syntax_error")]
ConfigValidateScriptSyntaxError,
#[serde(rename = "config_validate::script_missing_main")]
ConfigValidateScriptMissingMain,
#[serde(rename = "config_validate::file_not_found")]
ConfigValidateFileNotFound,
#[serde(rename = "config_validate::yaml_syntax_error")]
ConfigValidateYamlSyntaxError,
#[serde(rename = "config_validate::yaml_read_error")]
ConfigValidateYamlReadError,
#[serde(rename = "config_validate::yaml_mapping_error")]
ConfigValidateYamlMappingError,
#[serde(rename = "config_validate::merge_syntax_error")]
ConfigValidateMergeSyntaxError,
#[serde(rename = "config_validate::merge_mapping_error")]
ConfigValidateMergeMappingError,
#[serde(rename = "config_core::change_success")]
ConfigCoreChangeSuccess,
#[serde(rename = "config_core::change_error")]
ConfigCoreChangeError,
#[serde(rename = "mixed_port::fallback")]
MixedPortFallback,
#[serde(rename = "mixed_port::fallback_error")]
MixedPortFallbackError,
}
impl NoticeStatus {
/// Every variant in golden order. The wire-contract export and the golden
/// test both derive their variant list from here; expected strings stay
/// hand-written in the test so renames cannot slip through.
pub const ALL: [Self; 34] = [
Self::Info,
Self::ImportSubUrlOk,
Self::ImportSubUrlError,
Self::SetConfigError,
Self::CoreStartError,
Self::ServiceCoreRepairRequired,
Self::ServiceCoreAppDataNotOwned,
Self::ServiceCoreSidecarFallback,
Self::DnsOverrideAutoDisabled,
Self::EnhanceDiscardedKeys,
Self::TunModeAutoDisabled,
Self::TunModeAutoDisableFailed,
Self::AppRestartCoreStopFailed,
Self::AppQuitCoreStopFailed,
Self::UpdateWithClashProxy,
Self::UpdateFailedEvenWithClash,
Self::ReactivateProfilesError,
Self::UpdateFailed,
Self::ConfigValidateBootError,
Self::ConfigValidateError,
Self::ConfigValidateProcessTerminated,
Self::ConfigValidateScriptError,
Self::ConfigValidateScriptSyntaxError,
Self::ConfigValidateScriptMissingMain,
Self::ConfigValidateFileNotFound,
Self::ConfigValidateYamlSyntaxError,
Self::ConfigValidateYamlReadError,
Self::ConfigValidateYamlMappingError,
Self::ConfigValidateMergeSyntaxError,
Self::ConfigValidateMergeMappingError,
Self::ConfigCoreChangeSuccess,
Self::ConfigCoreChangeError,
Self::MixedPortFallback,
Self::MixedPortFallbackError,
];
}
#[cfg(test)]
mod tests {
use super::NoticeStatus;
#[test]
fn notice_status_wire_contract() -> Result<(), serde_json::Error> {
let statuses = NoticeStatus::ALL;
let expected = [
"info",
"import_sub_url::ok",
"import_sub_url::error",
"set_config::error",
"core_start::error",
"service_core::repair_required",
"service_core::app_data_not_owned",
"service_core::sidecar_fallback",
"dns_override::auto_disabled",
"enhance::discarded_keys",
"tun_mode::auto_disabled",
"tun_mode::auto_disable_failed",
"app_restart::core_stop_failed",
"app_quit::core_stop_failed",
"update_with_clash_proxy",
"update_failed_even_with_clash",
"reactivate_profiles::error",
"update_failed",
"config_validate::boot_error",
"config_validate::error",
"config_validate::process_terminated",
"config_validate::script_error",
"config_validate::script_syntax_error",
"config_validate::script_missing_main",
"config_validate::file_not_found",
"config_validate::yaml_syntax_error",
"config_validate::yaml_read_error",
"config_validate::yaml_mapping_error",
"config_validate::merge_syntax_error",
"config_validate::merge_mapping_error",
"config_core::change_success",
"config_core::change_error",
"mixed_port::fallback",
"mixed_port::fallback_error",
];
let actual: Vec<_> = statuses
.into_iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
assert_eq!(actual, expected.map(serde_json::Value::from));
Ok(())
}
}
+24 -49
View File
@@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::Fil
}
#[cfg(windows)]
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_lock_file(path)
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_private_file(path)
}
/// Repairs an application data root Windows no longer reports as owned by the current user.
@@ -110,7 +110,7 @@ mod windows_owner {
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK,
GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
GetFileInformationByHandle, GetFileType, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
@@ -215,49 +215,22 @@ mod windows_owner {
Ok(file)
}
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
let sid = current_sid()?;
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let wide = wide_path(path)?;
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | READ_CONTROL | WRITE_DAC,
// Replacing a live lock would let a second instance lock a different file.
FILE_SHARE_READ | FILE_SHARE_WRITE,
&attributes,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error()).context("failed to open current-user lock file");
match create_file(path, &descriptor) {
Ok(file) => Ok(file),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
{
let file = open_file(path)?;
validate_private_file(&file, descriptor.owner()?)?;
Ok(file)
}
Err(error) => Err(error),
}
let file = unsafe { std::fs::File::from_raw_handle(handle) };
let mut information = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
return Err(std::io::Error::last_os_error()).context("failed to inspect current-user lock file");
}
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|| unsafe { GetFileType(file.as_raw_handle()) } != FILE_TYPE_DISK
|| information.nNumberOfLinks != 1
{
bail!("current-user lock is not an ordinary, single-link file");
}
let expected_owner = descriptor.owner()?;
if !owner_matches(&file, expected_owner)? {
bail!("current-user lock belongs to a different Windows user");
}
// Lock contents are never trusted; records and tokens retain their separate validation.
descriptor.apply_dacl(file.as_raw_handle())?;
validate_private_file(&file, expected_owner)?;
Ok(file)
}
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
@@ -419,10 +392,13 @@ mod windows_owner {
}
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
// Elevated Windows processes can create directories owned by Administrators.
std::fs::create_dir_all(root).context("failed to create the application data root before owner repair")?;
// The Service canonicalizes before checking, so resolve junctions to the same object.
let root = std::fs::canonicalize(root).context("failed to canonicalize the application data root")?;
let root = match std::fs::canonicalize(root) {
Ok(path) => path,
// Nothing to repair: this process creates the root and owns it by construction.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error).context("failed to canonicalize the application data root"),
};
let root = root.as_path();
let sid = current_sid()?;
@@ -469,8 +445,7 @@ mod windows_owner {
{
continue;
}
// Failed inspection does not prove that a live lock belongs to another user.
Err(error) if name == crate::utils::server::INSTANCE_LOCK_FILE => return Err(error),
// A DACL that refuses even the owner read settles it just as well.
Err(_) => false,
};
if !ours {
@@ -740,7 +715,7 @@ mod windows_owner {
)
};
if status != 0 {
bail!("failed to restrict current-user file DACL: Windows error {status}");
bail!("failed to restrict owner token DACL: Windows error {status}");
}
Ok(())
}
+16 -25
View File
@@ -522,34 +522,25 @@ pub async fn clear() -> Result<()> {
/// A failed system call is usually a transient RPC hiccup, so give it a few tries.
async fn clear_with_retry() -> Result<()> {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add((CLEAR_ATTEMPTS - 1) as usize),
CLEAR_RETRY_DELAY,
),
|attempt| async move {
clear_inner().await.map_err(|error| {
for attempt in 1..CLEAR_ATTEMPTS {
match clear_inner().await {
Err(error)
if matches!(
SysproxyFailure::from_chain(&error),
Some(SysproxyFailure::SystemCallFailed)
) {
if attempt + 1 < CLEAR_ATTEMPTS as usize {
logging!(
warn,
Type::Core,
"clearing the system proxy failed (attempt {}/{CLEAR_ATTEMPTS}); retrying: {error:#}",
attempt + 1
);
}
RetryError::Retry(error)
} else {
RetryError::Stop(error)
}
})
},
)
.await
) =>
{
logging!(
warn,
Type::Core,
"clearing the system proxy failed (attempt {attempt}/{CLEAR_ATTEMPTS}); retrying: {error:#}"
);
tokio::time::sleep(CLEAR_RETRY_DELAY).await;
}
other => return other,
}
}
clear_inner().await
}
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
+308 -120
View File
@@ -1,23 +1,16 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>,
pub group_scopes: BTreeMap<String, GroupScope>,
pub proxies: Proxies,
pub providers: Option<ProxyProviders>,
}
/// The providers a group draws from (Mihomo order) and its `exclude-type` entries.
pub struct GroupScope {
pub providers: Vec<String>,
pub exclude_types: Vec<String>,
}
pub struct ProxyViewBuilder;
#[derive(Debug, PartialEq, Eq, Serialize)]
@@ -75,7 +68,6 @@ pub struct ProxyGroupView {
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
pub history: Vec<DelayHistory>,
pub extra: HashMap<String, Extra>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
@@ -90,7 +82,6 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType,
pub alive: bool,
pub history: Vec<DelayHistory>,
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -176,41 +167,15 @@ pub struct ProxySubscriptionInfo {
pub expire: i64,
}
/// Proxy name -> `(provider name, record id)` in provider order.
type ProviderCandidates = BTreeMap<String, Vec<(String, String)>>;
struct MemberResolver<'a> {
group_names: BTreeSet<String>,
group_scopes: &'a BTreeMap<String, GroupScope>,
records: &'a BTreeMap<String, ProxyNodeView>,
core_node_ids: &'a BTreeMap<String, String>,
provider_candidates: &'a ProviderCandidates,
provider_candidates: &'a BTreeMap<String, Vec<String>>,
provider_available: bool,
}
impl<'a> MemberResolver<'a> {
/// Same-name records in the order Mihomo lists them in `group`: `use` order, after `exclude-type`.
fn scoped_candidates(&self, group: &str, name: &str) -> Vec<&'a String> {
let (Some(scope), Some(candidates)) = (self.group_scopes.get(group), self.provider_candidates.get(name)) else {
return Vec::new();
};
scope
.providers
.iter()
.flat_map(|provider| candidates.iter().filter(move |(owner, _)| owner == provider))
.map(|(_, record_id)| record_id)
.filter(|record_id| {
!self.records.get(*record_id).is_some_and(|node| {
scope
.exclude_types
.iter()
.any(|excluded| excluded.eq_ignore_ascii_case(node.proxy_type.as_str()))
})
})
.collect()
}
fn resolve(&self, name: String, scoped: Option<&String>) -> ProxyMemberRef {
impl MemberResolver<'_> {
fn resolve(&self, name: String) -> ProxyMemberRef {
if self.group_names.contains(&name) {
ProxyMemberRef::Group { name }
} else if let Some(record_id) = self.core_node_ids.get(&name) {
@@ -224,17 +189,16 @@ impl<'a> MemberResolver<'a> {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
}
} else {
let candidates = self.provider_candidates.get(&name).map_or(&[][..], Vec::as_slice);
match (scoped, candidates) {
(Some(record_id), _) | (None, [(_, record_id)]) => ProxyMemberRef::Node {
match self.provider_candidates.get(&name).map(Vec::as_slice) {
Some([record_id]) => ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
},
(None, []) => ProxyMemberRef::Unresolved {
None => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Missing,
},
(None, _) => ProxyMemberRef::Unresolved {
Some(_) => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Ambiguous,
},
@@ -247,7 +211,6 @@ impl ProxyViewBuilder {
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
let ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
} = input;
@@ -256,13 +219,11 @@ impl ProxyViewBuilder {
} else {
ProxyViewProviderState::Unavailable
};
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut core_groups, core_nodes) = partition_core(proxies);
let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver {
group_names: core_groups.keys().cloned().collect(),
group_scopes: &group_scopes,
records: &records,
core_node_ids: &core_node_ids,
provider_candidates: &provider_candidates,
provider_available: provider_state == ProxyViewProviderState::Ready,
@@ -289,14 +250,14 @@ impl ProxyViewBuilder {
}
}
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
if proxy.all.is_some() {
groups.insert(name, proxy);
} else if !providers_available || proxy.provider_name.is_empty() {
} else {
nodes.insert(name, proxy);
}
}
@@ -330,7 +291,7 @@ fn build_core_records(
fn build_provider_records(
providers: Option<ProxyProviders>,
records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
let providers = providers
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
.unwrap_or_default();
@@ -361,7 +322,7 @@ fn build_provider_records(
candidates
.entry(proxy_name.clone())
.or_insert_with(Vec::new)
.push((provider_name.clone(), record_id.clone()));
.push(record_id.clone());
records.insert(
record_id.clone(),
build_node(
@@ -404,7 +365,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
test_url,
alive,
history,
extra,
udp,
xudp,
tfo,
@@ -413,20 +373,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
proxy_type,
..
} = proxy;
// The n-th occurrence of a name in `all` is the n-th scoped candidate.
let mut seen = BTreeMap::<String, (usize, Vec<&String>)>::new();
let members = all
.unwrap_or_default()
.into_iter()
.map(|member| {
let (occurrence, scoped) = seen
.entry(member.clone())
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
let picked = scoped.get(*occurrence).or_else(|| scoped.first()).copied();
*occurrence += 1;
resolver.resolve(member, picked)
})
.collect();
ProxyGroupView {
name,
@@ -438,7 +384,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon,
test_url,
history,
extra,
capabilities: ProxyCapabilities {
udp,
xudp,
@@ -446,7 +391,11 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
mptcp,
smux,
},
members,
members: all
.unwrap_or_default()
.into_iter()
.map(|name| resolver.resolve(name))
.collect(),
}
}
@@ -458,7 +407,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
test_url,
alive,
history,
extra,
udp,
xudp,
tfo,
@@ -474,7 +422,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
proxy_type,
alive,
history,
extra,
id,
hidden,
icon,
@@ -530,60 +477,301 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use tauri_plugin_mihomo::models::{
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
};
use super::{
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
ProxyViewOrderSource, ProxyViewProviderState,
};
fn node(name: &str) -> Proxy {
Proxy {
name: name.to_owned(),
proxy_type: ProxyType::Shadowsocks,
alive: true,
udp: true,
..Proxy::default()
}
}
fn group(name: &str, members: &[&str]) -> Proxy {
Proxy {
name: format!("ignored-{name}"),
proxy_type: ProxyType::Selector,
alive: true,
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
..Proxy::default()
}
}
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
let entries = [
("Zulu", group("Zulu", &["node-b", "node-b"])),
("Alpha", group("Alpha", &["node-a"])),
("node-b", node("wrong-node-b")),
("node-a", node("wrong-node-a")),
("A-before-direct", node("wrong-before-direct")),
("DIRECT", node("wrong-direct")),
("REJECT", node("wrong-reject")),
];
let mut proxies = HashMap::new();
if reverse_insert {
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
} else {
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
}
ProxyViewInput {
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
proxies: Proxies { proxies },
providers: None,
}
}
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
let providers = entries
.into_iter()
.map(|(provider_key, vehicle_type, names)| {
let provider = ProxyProvider {
name: format!("ignored-{provider_key}"),
vehicle_type,
proxies: names.iter().map(|name| node(name)).collect(),
..ProxyProvider::default()
};
(provider_key.to_owned(), provider)
})
.collect();
ProxyProviders { providers }
}
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers_fixture(vec![
("a", VehicleType::HTTP, &["duplicate"]),
("b", VehicleType::File, &["duplicate"]),
])),
}
}
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers,
}
}
fn input_with_provider_duplicates() -> ProxyViewInput {
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![
("z-provider", VehicleType::HTTP, &["z-node"]),
("unsupported", VehicleType::Compatible, &["ignored"]),
("a-provider", VehicleType::File, &["same", "same"]),
])),
}
}
fn serde_input_with_provider() -> ProxyViewInput {
let provider = ProxyProvider {
name: "ignored-object-name".to_owned(),
vehicle_type: VehicleType::HTTP,
updated_at: None,
subscription_info: Some(SubScriptionInfo {
upload: 1,
download: 2,
total: 3,
expire: 4,
}),
..ProxyProvider::default()
};
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(ProxyProviders {
providers: HashMap::from([("provider-key".to_owned(), provider)]),
}),
}
}
#[test]
fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into(), "B".into()],
group_scopes: ["A", "B"]
.into_iter()
.map(|name| {
(
name.into(),
GroupScope {
providers: vec![name.into()],
exclude_types: vec![],
},
)
})
.collect(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"B": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}
}}))?,
providers: Some(serde_json::from_value(json!({"providers": {
"A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]},
"B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]}
}}))?),
});
for (group, expected) in view.groups.iter().zip([32, 123]) {
let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else {
anyhow::bail!("Expected node")
};
let record = &view.records[record_id];
assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected));
assert!(
matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name)
);
}
Ok(())
fn ordering_and_core_records_are_deterministic() {
let first = ProxyViewBuilder::build(ordered_input(false));
let second = ProxyViewBuilder::build(ordered_input(true));
assert_eq!(first, second);
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
assert_eq!(
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
assert_eq!(first.direct.as_deref(), Some("c:1"));
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
assert_eq!(first.records["c:0"].name, "A-before-direct");
assert_eq!(first.records["c:1"].name, "DIRECT");
assert_eq!(first.records["c:4"].name, "node-b");
assert_eq!(first.groups[0].members.len(), 2);
assert!(matches!(
&first.groups[0].members[0],
ProxyMemberRef::Node { name, .. } if name == "node-b"
));
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
}
#[test]
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
fn fallback_and_unmatched_groups_are_stable() {
let mut input = ordered_input(false);
input.runtime_group_order = vec![];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Missing".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Zulu".into(), "Zulu".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
}
#[test]
fn global_and_member_resolution_follow_the_required_priority() {
let mut proxies = HashMap::new();
proxies.insert(
"GLOBAL".into(),
group("wrong", &["Nested", "core", "provider-only", "missing"]),
);
proxies.insert("Nested".into(), group("wrong", &[]));
proxies.insert("core".into(), node("wrong"));
let providers = providers_fixture(vec![
(
"a",
VehicleType::HTTP,
&["Nested", "core", "provider-only", "duplicate"],
),
("b", VehicleType::File, &["duplicate"]),
]);
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into()],
group_scopes: BTreeMap::new(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "provider"}
}}))?,
providers: None,
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers),
});
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
Ok(())
let members = &view.global.as_ref().expect("GLOBAL").members;
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
assert!(matches!(
members[3],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Missing,
..
}
));
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
}
#[test]
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
assert!(matches!(
ready.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Ambiguous,
..
}
));
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
assert!(matches!(
unavailable.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
..
}
));
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
assert!(unavailable.providers.is_empty());
}
#[test]
fn providers_preserve_identity_duplicates_and_member_order() {
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
assert_eq!(
view.providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["a-provider", "z-provider"]
);
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
assert_eq!(view.records["p:0:0"].name, "same");
assert_eq!(view.records["p:0:1"].name, "same");
assert_eq!(
view.records["p:0:0"].source,
ProxyNodeSource::Provider {
provider_name: "a-provider".into(),
proxy_name: "same".into(),
}
);
}
#[test]
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![(
"unsupported",
VehicleType::Compatible,
&["ignored"],
)])),
});
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
assert!(view.providers.is_empty());
}
#[test]
fn serde_matches_the_v1_wire_contract() {
let view = ProxyViewBuilder::build(serde_input_with_provider());
let json = serde_json::to_value(view).expect("serialize view");
assert_eq!(json["schemaVersion"], 1);
assert_eq!(json["orderSource"], "fallback");
assert_eq!(json["providerState"], "ready");
assert!(json["global"].is_null());
assert!(json["direct"].is_null());
assert!(json["providers"][0].get("vehicleType").is_some());
assert!(json["providers"][0].get("updatedAt").is_none());
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
}
}
+2 -46
View File
@@ -27,42 +27,8 @@ pub struct RealEnv;
impl RunStateEnv for RealEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
// The IPC client would otherwise back off for about two minutes before failing.
#[cfg(windows)]
if let Some(reason) = tokio::task::spawn_blocking(crate::core::service::service_stop_reason)
.await
.context("service status probe did not finish")??
{
anyhow::bail!(reason);
}
let response = clash_verge_service_ipc::get_version().await?;
let core = if response.code == 0
&& response.data.as_ref().is_some_and(|info| {
info.supports_client(
clash_verge_service_ipc::ProtocolVersion::current(),
clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION,
)
}) {
let name = format!(
"{}{}",
crate::config::Config::verge().await.latest_arc().get_valid_clash_core(),
std::env::consts::EXE_SUFFIX
);
let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement {
name: name.clone(),
sha256: None,
}])
.await?;
status
.cores
.into_iter()
.find(|core| core.name == name)
.map(|core| core.availability)
} else {
None
};
Ok(ServiceVersionReply {
core,
code: response.code,
message: response.message,
protocol: response.data,
@@ -70,17 +36,9 @@ impl RunStateEnv for RealEnv {
}
async fn trusted_install_evidence(&self) -> Result<bool> {
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
.await
.context("service registration probe did not finish")??;
// A helper that outlived its registration is a broken Service, not an absent one.
#[cfg(unix)]
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
return Ok(error
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
.is_some());
}
Ok(registered)
.context("service registration probe did not finish")?
}
fn is_elevated(&self) -> bool {
@@ -161,7 +119,6 @@ mod fake {
#[must_use]
pub fn service_ready(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -171,7 +128,6 @@ mod fake {
#[must_use]
pub fn service_version_mismatch(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
+17 -40
View File
@@ -10,7 +10,7 @@ mod probe;
use std::{
sync::{
Arc, LazyLock,
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
@@ -19,6 +19,7 @@ use std::{
use anyhow::{Context as _, Result, bail};
use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use tokio::sync::Notify;
@@ -27,14 +28,14 @@ pub use env::FakeEnv;
pub use env::{RealEnv, RunStateEnv};
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
use crate::core::manager::RunningMode;
use health::StoredService;
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
/// The process-wide Run State, observing the real machine.
pub static RUN_STATE: LazyLock<RunStateStore<RealEnv>> = LazyLock::new(|| RunStateStore::new(RealEnv));
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
/// Distinguishes silence from a reply that updated Service health with a rejection.
#[derive(Debug)]
@@ -129,21 +130,20 @@ impl<E: RunStateEnv> RunStateStore<E> {
/// Retries transport failures but stops at the first readable reply, including a rejection.
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let attempts = std::num::NonZeroUsize::new(attempts).ok_or_else(|| {
ReadyWaitError::Unreachable(anyhow::anyhow!(
"service readiness wait was configured with no attempts"
))
})?;
retry(RetryPolicy::fixed(attempts, interval), |_| async {
let mut last_error = None;
for attempt in 0..attempts {
match self.env.probe_service_version().await {
Ok(reply) => self
.record_reply(&reply)
.map_err(|error| RetryError::Stop(ReadyWaitError::Rejected(error))),
Err(error) => Err(RetryError::Retry(ReadyWaitError::Unreachable(error))),
Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected),
Err(error) => last_error = Some(error),
}
})
.await
if attempt + 1 < attempts {
tokio::time::sleep(interval).await;
}
}
Err(ReadyWaitError::Unreachable(last_error.unwrap_or_else(|| {
anyhow::anyhow!("service readiness wait was configured with no attempts")
})))
}
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
@@ -152,10 +152,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.observe(ServiceHealth::Ready);
Ok(self.state())
}
ServiceVersionCheck::CoreUnavailable(error) => {
self.observe(ServiceHealth::Unavailable(error.clone()));
bail!(error)
}
ServiceVersionCheck::NeedsReinstall(error) => {
self.observe(ServiceHealth::VersionMismatch);
bail!(error)
@@ -182,10 +178,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
match self.env.probe_service_version().await {
Ok(reply) => match classify_service_version_reply(&reply) {
ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason),
_ => classify_service_health(probe_outcome(&reply), has_marker, ""),
},
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
Err(error) => {
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
classify_service_health(
@@ -330,21 +323,6 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
#[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending");
}
// Publish the refusal and allowance together so it never asks the UI for a repair.
state.service.health = ServiceHealth::Unavailable(reason);
state.service.allow_sidecar();
state.bump();
drop(state);
self.announce();
Ok(())
}
/// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
@@ -494,7 +472,6 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
+13 -50
View File
@@ -4,9 +4,7 @@
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
//! systemd, SCM or launchd.
use clash_verge_service_ipc::{
MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion, VERSION as SERVICE_VERSION,
};
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
use super::health::ServiceHealth;
@@ -16,7 +14,6 @@ pub struct ServiceVersionReply {
pub code: u16,
pub message: String,
pub protocol: Option<ProtocolInfo>,
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
}
/// The verdict on a [`ServiceVersionReply`].
@@ -24,12 +21,8 @@ pub struct ServiceVersionReply {
pub enum ServiceVersionCheck {
Ready,
NeedsReinstall(String),
CoreUnavailable(String),
}
/// Leads the Unavailable reason when the Service refused the approved core itself.
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
@@ -39,30 +32,22 @@ pub enum CurrentServiceProbe {
Unavailable,
}
/// Require the bundled Service version as well as a usable protocol.
/// Decide whether the Service we reached speaks a protocol this client can use.
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
let client = ProtocolVersion::current();
if reply.code == 0
&& reply.protocol.as_ref().is_some_and(|info| {
info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)
})
&& reply
.protocol
.as_ref()
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
{
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
),
};
return ServiceVersionCheck::Ready;
}
let detail = if reply.code == 0 {
match reply.protocol.as_ref() {
Some(info) => format!(
"client requires service version {} with epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
SERVICE_VERSION,
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
client.epoch,
MIN_REQUIRED_SERVICE_REVISION,
info.protocol.epoch,
@@ -78,16 +63,16 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
)
};
ServiceVersionCheck::NeedsReinstall(format!(
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
))
}
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
match classify_service_version_reply(reply) {
ServiceVersionCheck::Ready => CurrentServiceProbe::Ready,
ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch,
ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable,
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
CurrentServiceProbe::Ready
} else {
CurrentServiceProbe::VersionMismatch
}
}
@@ -117,7 +102,6 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -133,26 +117,9 @@ mod tests {
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
}
#[test]
fn compatible_service_with_missing_core_is_unavailable() {
for core in [
None,
Some(clash_verge_service_ipc::CoreAvailability::Missing),
Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch),
] {
let reply = ServiceVersionReply { core, ..ready_reply() };
assert!(matches!(
classify_service_version_reply(&reply),
ServiceVersionCheck::CoreUnavailable(_)
));
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable);
}
}
#[test]
fn a_reply_without_protocol_information_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
@@ -170,7 +137,6 @@ mod tests {
#[test]
fn a_nonzero_code_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 7,
message: "nope".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -186,7 +152,6 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -207,7 +172,6 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.revision = revision;
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -226,7 +190,6 @@ mod tests {
for protocol in [None, Some(old_epoch)] {
for code in [0, 1] {
let reply = ServiceVersionReply {
core: None,
code,
message: "unsupported command".to_owned(),
protocol: protocol.clone(),
+2 -176
View File
@@ -2,13 +2,11 @@ use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
"ASN.mmdb",
"geoip.dat",
"geosite.dat",
"geoip.metadb",
@@ -55,94 +53,6 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
}
#[derive(Default)]
struct ProviderPathOwners {
remote: BTreeMap<String, Vec<(&'static str, Value)>>,
local: bool,
}
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
let config_root = std::fs::canonicalize(config_root)?;
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
for section in ["proxy-providers", "rule-providers"] {
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
continue;
};
for (name, provider) in providers {
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
continue;
};
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
let destination = if is_remote {
provider_destination(&config_root, raw_path)
} else {
local_provider_source(&config_root, raw_path)
.and_then(|source| destination_below_root(&config_root, &source))
.or_else(|_| provider_destination(&config_root, raw_path))
};
let Ok(destination) = destination else {
continue;
};
reserved.insert(destination.clone());
let owners = paths.entry(destination).or_default();
match (is_remote, provider.get("url").and_then(Value::as_str)) {
(true, Some(url)) => owners
.remote
.entry(url.to_owned())
.or_default()
.push((section, name.clone())),
_ => owners.local = true,
}
}
}
for (destination, owners) in paths {
if owners.local || owners.remote.len() < 2 {
continue;
}
// Allocate every source in a conflict group, so declaration order cannot select a cache's owner.
for (url, providers) in owners.remote {
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
for (section, name) in providers {
config[section][&name]["path"] = Value::String(replacement.clone());
logging!(
warn,
Type::Config,
"provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}"
);
}
}
}
Ok(())
}
fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet<String>) -> String {
let mut digest = Sha256::new();
digest.update(destination.as_bytes());
digest.update([0]);
digest.update(url.as_bytes());
let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
let path = Path::new(destination);
let extension = path
.extension()
.map(|ext| format!(".{}", ext.to_string_lossy()))
.unwrap_or_default();
let mut suffix = String::new();
let mut sequence = 0;
loop {
let candidate = path
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
.to_string_lossy()
.replace('\\', "/");
if reserved.insert(candidate.clone()) {
return candidate;
}
sequence += 1;
suffix = format!("-{sequence}");
}
}
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
let config_root = std::fs::canonicalize(config_root)?;
let mut assets = Vec::new();
@@ -344,91 +254,7 @@ fn normalized_destination(relative: &Path) -> Result<String> {
#[cfg(test)]
mod tests {
use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts};
#[tokio::test]
async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id()));
std::fs::create_dir_all(&root)?;
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
)?;
let original = config.clone();
let root = std::fs::canonicalize(root)?;
let mut reordered = config.clone();
reordered["rule-providers"] = serde_yaml_ng::from_str(&format!(
"mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
root.join("providers/rule/AppleDev.yaml").display(),
))?;
resolve_provider_path_conflicts(&mut config, &root)?;
let config_file = root.join("config.yaml");
std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?;
let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?;
let paths = &config["rule-providers"];
assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]);
resolve_provider_path_conflicts(&mut reordered, &root)?;
assert_eq!(
config, reordered,
"path allocation must not depend on declaration order or path spelling"
);
let resolved = config.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_eq!(
config, resolved,
"resolving an already generated config must be idempotent"
);
assert_eq!(serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&bundle.yaml)?, config);
let readback = remote_providers_of(&config, &root)?;
assert_eq!(bundle.remote_providers.len(), 2);
for declared in readback {
assert!(bundle.remote_providers.contains(&declared.provider));
assert_eq!(
config[declared.section][&declared.name]["path"],
declared.provider.destination
);
assert_eq!(
config[declared.section][&declared.name]["url"],
original[declared.section][&declared.name]["url"]
);
}
std::fs::remove_dir_all(root)?;
Ok(())
}
#[test]
fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> {
let root = std::env::temp_dir();
let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n",
)?;
let mut config = original.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
let allocated = config["proxy-providers"]["a"]["path"].clone();
let mut config = original;
config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?;
config["rule-providers"]["reserved"]["path"] = allocated.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_ne!(config["proxy-providers"]["a"]["path"], allocated);
assert_ne!(
config["proxy-providers"]["a"]["path"],
config["rule-providers"]["b"]["path"]
);
assert_eq!(config["rule-providers"]["reserved"]["path"], allocated);
Ok(())
}
#[test]
fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> {
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n",
)?;
let original = config.clone();
resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?;
assert_eq!(config, original);
Ok(())
}
use super::collect_runtime_bundle;
#[tokio::test]
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -477,9 +477,7 @@ impl Timer {
Ok(_) => {
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
}
Err(e) => {
logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid);
}
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid),
},
|| Self::emit_update_event(uid, false),
)
-156
View File
@@ -1,156 +0,0 @@
use super::Tray;
use crate::{
Type, cmd,
config::Config,
core::{
handle,
notify::{Refresh, announce},
proxy_view::{ProxyMemberRef, ProxyNodeSource},
},
logging,
};
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
fn is_latency_test_url(raw: &str) -> bool {
let raw = raw.trim();
raw.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
}
pub(super) fn test_url(
group: &crate::core::proxy_view::ProxyGroupView,
verge: &crate::config::IVerge,
profiles: &crate::config::IProfiles,
) -> String {
let custom_url = profiles
.current
.as_ref()
.and_then(|uid| profiles.get_item(uid).ok())
.and_then(|profile| profile.latency_test_urls.as_ref())
.and_then(|urls| urls.get(group.name.as_str()))
.map(|url| url.as_str())
.filter(|url| !url.trim().is_empty());
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
let default_url = verge
.default_latency_test
.as_deref()
.filter(|url| !url.trim().is_empty());
custom_url
.or(group_url)
.or(default_url)
.filter(|&url| is_latency_test_url(url))
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
.trim()
.to_owned()
}
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
parking_lot::Mutex::new(std::collections::BTreeSet::new());
pub(super) fn is_running(group_name: &str) -> bool {
RUNNING.lock().contains(group_name)
}
pub(super) async fn test_proxy_group_delay(group_name: &str) {
if !RUNNING.lock().insert(group_name.to_owned()) {
return;
}
Tray::global().refresh_latency_item(group_name);
let _running = scopeguard::guard(group_name, |name| {
RUNNING.lock().remove(name);
Tray::global().refresh_latency_item(name);
});
use futures::{StreamExt as _, stream};
let view = match cmd::proxy::get_proxy_view().await {
Ok(view) => view,
Err(err) => {
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
return;
}
};
let verge = Config::verge().await.latest_arc();
let group = view
.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == group_name);
let Some(group) = group else {
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
return;
};
let profiles = Config::profiles().await.latest_arc();
let url = test_url(group, &verge, &profiles);
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000) as u32;
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
for member in &group.members {
match member {
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
Some(ProxyNodeSource::Provider {
provider_name,
proxy_name,
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
_ => targets.push((name.clone(), None)),
},
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
ProxyMemberRef::Unresolved { .. } => {}
}
}
let mihomo = handle::Handle::mihomo();
stream::iter(targets.into_iter().map(move |(member, provider)| {
let url = url.clone();
async move {
let result = match provider {
Some(provider_name) => {
mihomo
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
.await
}
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
};
if let Err(err) = result {
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
}
}
}))
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
.for_each(|()| std::future::ready(()))
.await;
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
}
announce(Refresh::Proxies);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_urls_require_a_valid_http_host_and_port() {
for invalid in [
"http://?",
"http://host:invalid",
"http://bad host/",
"ftp://localhost",
"http:localhost",
] {
assert!(!is_latency_test_url(invalid), "{invalid}");
}
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
assert!(is_latency_test_url(valid), "{valid}");
}
}
}

Some files were not shown because too many files have changed in this diff Show More