mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 22:00:56 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4337bd8575 | ||
|
|
cb751052a4 | ||
|
|
d9dc18a57b | ||
|
|
6b4d4e1551 | ||
|
|
4a125681bd | ||
|
|
1c2817023e | ||
|
|
89076f01c3 | ||
|
|
a4531cf926 | ||
|
|
07b0e71cc6 | ||
|
|
ad2baf254a | ||
|
|
d01f43cc78 | ||
|
|
720dc13e96 | ||
|
|
69b76cc6a9 | ||
|
|
8328e34208 | ||
|
|
78691ea5c8 | ||
|
|
af808a677c | ||
|
|
a1bebf6de1 | ||
|
|
2caff762b5 | ||
|
|
dba0f471bf | ||
|
|
c341b9b238 | ||
|
|
6ec35c8195 | ||
|
|
30f05a7a39 | ||
|
|
7cc6c1ed95 | ||
|
|
c7cee50fc1 | ||
|
|
4cae1ff132 | ||
|
|
1a01844cb8 | ||
|
|
1575f134e0 | ||
|
|
a9a3cfa3a7 | ||
|
|
14770df157 | ||
|
|
57c5484080 | ||
|
|
4a0882fc18 | ||
|
|
d87bb7b6a3 | ||
|
|
a58cefdc31 | ||
|
|
f958dccb25 | ||
|
|
8c1fb3ac56 | ||
|
|
3607e66bb3 | ||
|
|
ea509b8236 | ||
|
|
52d616302c | ||
|
|
8ddd99f226 | ||
|
|
e0e97a920e | ||
|
|
c4f9d65e07 | ||
|
|
0c7e0c8535 | ||
|
|
1638ab48d8 | ||
|
|
5d7dc3aed9 | ||
|
|
b057bd964c | ||
|
|
897a117dc5 | ||
|
|
6a752994cf | ||
|
|
32e5d5bda3 | ||
|
|
daae17a40c | ||
|
|
1f140dfd3a | ||
|
|
04d74ac48f | ||
|
|
78bf79cc09 | ||
|
|
61c33c6113 | ||
|
|
ae4f416766 | ||
|
|
fbc4b516b8 | ||
|
|
3456de1fb6 | ||
|
|
f9ef9d78cb | ||
|
|
3458c9b075 | ||
|
|
afc32f568a |
@@ -5,10 +5,10 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.5.1"
|
||||
"pnpmVersion": "12.10.0"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.98.1",
|
||||
"version": "1.99.0",
|
||||
"profile": "default"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.89.15": {
|
||||
"github/gh-aw-actions/setup@v0.89.21": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.89.15",
|
||||
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
|
||||
"version": "v0.89.21",
|
||||
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
@@ -27,6 +27,11 @@
|
||||
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
|
||||
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||
@@ -42,6 +47,11 @@
|
||||
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
|
||||
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||
@@ -57,20 +67,25 @@
|
||||
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
|
||||
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
|
||||
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
|
||||
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-node": {
|
||||
"image": "ghcr.io/github/gh-aw-node",
|
||||
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
||||
},
|
||||
"ghcr.io/github/github-mcp-server:v1.12.1": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
|
||||
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
|
||||
"ghcr.io/github/github-mcp-server:v1.12.2": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
|
||||
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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'
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
|
||||
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
|
||||
with:
|
||||
version: v0.89.15
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@1.98.1
|
||||
uses: dtolnay/rust-toolchain@1.99.0
|
||||
|
||||
- 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.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
|
||||
@@ -85,3 +85,82 @@ 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
|
||||
|
||||
+99
-58
File diff suppressed because one or more lines are too long
@@ -144,7 +144,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -487,7 +487,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || 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://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.5.1
|
||||
rust 1.98.1
|
||||
pnpm 12.10.0
|
||||
rust 1.99.0
|
||||
|
||||
Generated
+276
-214
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -47,6 +47,9 @@ 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
|
||||
@@ -63,13 +66,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
|
||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||
tracing = "0.1.44"
|
||||
|
||||
tauri = { version = "2.11.5" }
|
||||
tauri-plugin-clipboard-manager = "2.3.3"
|
||||
tauri = { version = "2.12.1" }
|
||||
tauri-plugin-clipboard-manager = "2.4.1"
|
||||
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.1", features = [
|
||||
tokio = { version = "1.53.2", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
|
||||
+34
-19
@@ -1,36 +1,51 @@
|
||||
## v2.5.5
|
||||
## v2.5.8
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
|
||||
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
|
||||
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
|
||||
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
|
||||
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
|
||||
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
|
||||
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
|
||||
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
|
||||
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
|
||||
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
|
||||
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
|
||||
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
|
||||
|
||||
**🐧 Linux**
|
||||
**🍎 macOS**
|
||||
|
||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
|
||||
|
||||
**🖥️/🍎 Windows/macOS**
|
||||
|
||||
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增系统托盘代理组中的「测试延迟」菜单项
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
- 优化端口设置窗口的实现,精简重复代码
|
||||
- 优化侧边栏流量图表的 CPU 占用
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
|
||||
|
||||
</details>
|
||||
|
||||
@@ -11,13 +11,14 @@ 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_ru.md">Русский</a> ·
|
||||
<a href="./docs/README_fa.md">فارسی</a> ·
|
||||
<a href="./docs/README_ja.md">日本語</a> ·
|
||||
<a href="./docs/README_ko.md">한국어</a> ·
|
||||
<a href="./docs/README_fa.md">فارسی</a>
|
||||
<a href="./docs/README_pt.md">Português</a> ·
|
||||
<a href="./docs/README_ru.md">Русский</a> ·
|
||||
<a href="./README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Preview
|
||||
@@ -31,6 +32,7 @@ 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)。
|
||||
|
||||
#### 我应当怎样选择发行版
|
||||
|
||||
@@ -50,14 +52,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访问**
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.2.2"
|
||||
rust-i18n = "4.2.4"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
core:
|
||||
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
|
||||
launchGuard:
|
||||
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
||||
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: عام
|
||||
profiles: الملفات التعريفية
|
||||
proxies: وكلاء
|
||||
testLatency: اختبار التأخير
|
||||
testingLatency: جارٍ اختبار التأخير…
|
||||
systemProxy: وكيل النظام
|
||||
tunMode: وضع TUN
|
||||
closeAllConnections: إغلاق كل الاتصالات
|
||||
|
||||
@@ -8,8 +8,8 @@ notifications:
|
||||
body: Auf {mode} umgeschaltet.
|
||||
systemProxyToggled:
|
||||
title: Systemproxy
|
||||
'on': System proxy has been enabled.
|
||||
'off': System proxy has been disabled.
|
||||
'on': Der Systemproxy wurde aktiviert.
|
||||
'off': Der Systemproxy wurde deaktiviert.
|
||||
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': TUN mode has been enabled.
|
||||
'off': TUN mode has been disabled.
|
||||
'on': Der TUN-Modus wurde aktiviert.
|
||||
'off': Der TUN-Modus wurde deaktiviert.
|
||||
lightweightModeEntered:
|
||||
title: Leichtmodus
|
||||
body: Leichtmodus aktiviert.
|
||||
@@ -30,13 +30,15 @@ notifications:
|
||||
title: Beenden steht bevor
|
||||
body: Clash Verge wird gleich beendet.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
installNow: Install Now
|
||||
later: Later
|
||||
title: Clash-Verge-Update
|
||||
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
|
||||
installNow: Jetzt installieren
|
||||
later: Später
|
||||
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}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profile
|
||||
proxies: Proxy
|
||||
testLatency: Latenz testen
|
||||
testingLatency: Latenz wird getestet…
|
||||
systemProxy: Systemproxy
|
||||
tunMode: TUN-Modus
|
||||
closeAllConnections: Alle Verbindungen schließen
|
||||
|
||||
@@ -37,6 +37,8 @@ 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}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
testLatency: Test Latency
|
||||
testingLatency: Testing latency…
|
||||
systemProxy: System Proxy
|
||||
tunMode: TUN Mode
|
||||
closeAllConnections: Close All Connections
|
||||
|
||||
@@ -37,6 +37,8 @@ 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}'
|
||||
@@ -68,6 +70,8 @@ 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
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
core:
|
||||
alreadyRunning: 'هسته شروع نمیشود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقیمانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راهاندازی مجدد کنید.'
|
||||
launchGuard:
|
||||
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
||||
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: سراسری
|
||||
profiles: پروفایلها
|
||||
proxies: پروکسیها
|
||||
testLatency: آزمایش تأخیر
|
||||
testingLatency: در حال آزمایش تأخیر…
|
||||
systemProxy: پروکسی سیستم
|
||||
tunMode: حالت TUN
|
||||
closeAllConnections: بستن همه اتصالها
|
||||
|
||||
@@ -37,6 +37,8 @@ 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}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profil
|
||||
proxies: Proksi
|
||||
testLatency: Uji Latensi
|
||||
testingLatency: Menguji latensi…
|
||||
systemProxy: Proksi Sistem
|
||||
tunMode: Mode TUN
|
||||
closeAllConnections: Tutup Semua Koneksi
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||
core:
|
||||
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
|
||||
launchGuard:
|
||||
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
||||
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: グローバル
|
||||
profiles: プロファイル
|
||||
proxies: プロキシ
|
||||
testLatency: 遅延テスト
|
||||
testingLatency: 遅延をテスト中…
|
||||
systemProxy: システムプロキシ
|
||||
tunMode: TUN モード
|
||||
closeAllConnections: すべての接続を閉じる
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||
core:
|
||||
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
|
||||
launchGuard:
|
||||
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
||||
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: 글로벌
|
||||
profiles: 프로필
|
||||
proxies: 프록시
|
||||
testLatency: 지연 테스트
|
||||
testingLatency: 지연 테스트 중…
|
||||
systemProxy: 시스템 프록시
|
||||
tunMode: TUN 모드
|
||||
closeAllConnections: 모든 연결 닫기
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
_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
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||
core:
|
||||
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
|
||||
launchGuard:
|
||||
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
||||
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: Глобальный
|
||||
profiles: Профили
|
||||
proxies: Прокси
|
||||
testLatency: Проверить задержку
|
||||
testingLatency: Проверка задержки…
|
||||
systemProxy: Системный прокси
|
||||
tunMode: Режим TUN
|
||||
closeAllConnections: Закрыть все соединения
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
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}'
|
||||
@@ -68,6 +70,8 @@ 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
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||
core:
|
||||
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
|
||||
launchGuard:
|
||||
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
||||
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: Глобаль
|
||||
profiles: Профильләр
|
||||
proxies: Проксилар
|
||||
testLatency: Кичектерүне тикшерү
|
||||
testingLatency: Кичектерү тикшерелә…
|
||||
systemProxy: Системалы прокси
|
||||
tunMode: TUN режимы
|
||||
closeAllConnections: Барлык тоташуларны ябу
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||
core:
|
||||
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
|
||||
launchGuard:
|
||||
currentExeError: 无法确定应用位置:{error}
|
||||
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
||||
@@ -68,6 +70,8 @@ tray:
|
||||
global: 全局
|
||||
profiles: 订阅
|
||||
proxies: 代理
|
||||
testLatency: 测试延迟
|
||||
testingLatency: 正在测试延迟…
|
||||
systemProxy: 系统代理
|
||||
tunMode: TUN 模式
|
||||
closeAllConnections: 关闭所有连接
|
||||
|
||||
@@ -37,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
||||
core:
|
||||
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
|
||||
launchGuard:
|
||||
currentExeError: 無法判斷應用程式位置:{error}
|
||||
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
||||
@@ -68,6 +70,8 @@ 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.189"
|
||||
libc = "0.2.190"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
deelevate = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,114 @@
|
||||
## 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>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
@@ -45,6 +154,11 @@
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
@@ -66,6 +180,11 @@
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
@@ -90,6 +209,7 @@
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.2
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@ 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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Preview
|
||||
@@ -51,11 +52,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
|
||||
## Promotion
|
||||
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
|
||||
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
#### **Core Advantages:**
|
||||
|
||||
@@ -67,7 +68,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
||||
- 🎬 Unlocks **streaming platforms and mainstream AI services**
|
||||
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@ 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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Vista previa
|
||||
@@ -51,17 +52,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
||||
|
||||
## Promociones
|
||||
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
|
||||
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
|
||||
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
|
||||
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
|
||||
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
|
||||
- Desbloquea servicios de streaming y acceso a ChatGPT
|
||||
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## پیشنمایش
|
||||
@@ -50,17 +51,17 @@
|
||||
|
||||
## تبلیغات
|
||||
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
||||
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
||||
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
||||
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
||||
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## プレビュー
|
||||
@@ -51,17 +52,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
||||
|
||||
## プロモーション
|
||||
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
||||
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
||||
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
||||
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
||||
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
||||
- ストリーミングおよび ChatGPT の利用にも対応
|
||||
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## 미리보기
|
||||
@@ -51,17 +52,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
||||
|
||||
## 프로모션
|
||||
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
||||
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
||||
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
||||
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
||||
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
||||
- 스트리밍 및 ChatGPT 접근 지원
|
||||
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<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 |
|
||||
| -------------------------------------- | -------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
## 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.
|
||||
+7
-6
@@ -11,13 +11,14 @@ 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_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
## Предпросмотр
|
||||
|
||||
@@ -47,17 +48,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
||||
|
||||
## Продвижение
|
||||
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
||||
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
||||
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
||||
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
||||
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
||||
- Разблокировка потоковые сервисы и ChatGPT
|
||||
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"ignoreBinaries": ["rustc", "cc"],
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"]
|
||||
"src/types/generated/**": ["exports", "types"],
|
||||
"src/services/contract.ts": ["exports", "types"]
|
||||
},
|
||||
"cycles": {
|
||||
"dynamicImports": true
|
||||
|
||||
+54
-52
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.8",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -30,6 +30,8 @@
|
||||
"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",
|
||||
@@ -49,90 +51,90 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.6.0",
|
||||
"@tauri-apps/plugin-opener": "2.5.5",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"ahooks": "^3.9.7",
|
||||
"@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",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
"js-yaml": "^5.0.0",
|
||||
"foxact": "^0.3.12",
|
||||
"foxts": "^5.9.1",
|
||||
"i18next": "^26.4.2",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.25",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"meta-json-schema": "^1.19.31",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"nanoid": "^6.0.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.5",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.14",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.0.15",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.4.2",
|
||||
"swr": "^2.5.1",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^9.1.1",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@tauri-apps/cli": "2.12.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-es-x": "^10.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-es-x": "^10.0.1",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-sukka": "^10.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"eslint-plugin-sukka": "^10.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"globals": "^17.6.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"globals": "^17.12.0",
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.35.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
"terser": "^5.48.0",
|
||||
"knip": "6.39.0",
|
||||
"lint-staged": "^17.5.1",
|
||||
"sass": "^1.104.1",
|
||||
"tar": "^7.5.22",
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "^8.0.14",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.0",
|
||||
"webdriverio": "9.31.9",
|
||||
"ws": "8.21.3"
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.0.0",
|
||||
"ws": "8.22.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -144,5 +146,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.5.1"
|
||||
"packageManager": "pnpm@12.10.0"
|
||||
}
|
||||
|
||||
Generated
+1398
-1547
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.98.1"
|
||||
channel = "1.99.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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')
|
||||
@@ -0,0 +1,91 @@
|
||||
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`,
|
||||
)
|
||||
+41
-19
@@ -30,29 +30,51 @@ 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
|
||||
|
||||
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}}
|
||||
')
|
||||
# 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
|
||||
|
||||
original_dns=$(networksetup -getdnsservers "$hardware_port")
|
||||
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; 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
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$is_valid_dns" = false ]; then
|
||||
echo "empty" >.original_dns.txt
|
||||
else
|
||||
echo "$original_dns" >.original_dns.txt
|
||||
fi
|
||||
networksetup -setdnsservers "$hardware_port" "$1"
|
||||
# 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
|
||||
|
||||
+57
-8
@@ -1,13 +1,62 @@
|
||||
#!/bin/bash
|
||||
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
||||
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
|
||||
|
||||
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 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"
|
||||
}
|
||||
|
||||
result=0
|
||||
# Older releases kept a single backup for whichever service was primary.
|
||||
if [ -f .original_dns.txt ]; then
|
||||
original_dns=$(cat .original_dns.txt)
|
||||
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||
rm -rf .original_dns.txt
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
+21
-21
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.5"
|
||||
version = "2.5.8"
|
||||
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.95"
|
||||
rust-version = "1.99"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
tauri-build = { version = "2.7.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
clash-verge-draft = { workspace = true }
|
||||
@@ -66,7 +66,6 @@ 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 }
|
||||
@@ -74,36 +73,37 @@ 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.3.6"
|
||||
tauri-plugin-opener = "2.5.5"
|
||||
tauri-plugin-dialog = "2.7.3"
|
||||
tauri-plugin-fs = "2.5.2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.10"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
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"
|
||||
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.4.0"
|
||||
tauri-plugin-notification = "2.5.1"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.6.0"
|
||||
tauri-plugin-http = "2.8.0"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.1", optional = 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" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.3", version = "2.7.3", features = [
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.5"
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
@@ -111,11 +111,11 @@ bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.189"
|
||||
libc = "0.2.190"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||
objc2 = "0.6.4"
|
||||
objc2 = "0.6.5"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
"NSDictionary",
|
||||
@@ -146,9 +146,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.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tauri-plugin-autostart = "2.7.0"
|
||||
tauri-plugin-global-shortcut = "2.4.0"
|
||||
tauri-plugin-updater = "2.13.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -901,6 +901,7 @@ Section Install
|
||||
|
||||
nsExec::Exec 'netsh int tcp res'
|
||||
|
||||
!include "Win\RestartManager.nsh"
|
||||
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||
!insertmacro CheckAllVergeProcesses
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! 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());
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
core::{SilentUpdater, updater::DownloadEvent},
|
||||
feat,
|
||||
utils::dirs,
|
||||
};
|
||||
use smartstring::alias::String;
|
||||
use tauri::{AppHandle, Manager as _};
|
||||
use tauri::{AppHandle, Manager as _, ipc::Channel};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_app_dir() -> CmdResult<()> {
|
||||
@@ -44,6 +49,25 @@ 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();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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::{
|
||||
@@ -56,14 +57,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
||||
|
||||
match CoreManager::global().restart_core().await {
|
||||
Ok(_) => {
|
||||
handle::Handle::notice_message("config_core::change_success", clash_core);
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
|
||||
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_message("config_core::change_error", error_msg.clone());
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
||||
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
@@ -72,7 +73,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_message("config_core::change_error", error_msg);
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::module::lightweight;
|
||||
use crate::core::lightweight;
|
||||
|
||||
use super::CmdResult;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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::{
|
||||
@@ -282,7 +283,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_message("config_validate::boot_error", message.clone());
|
||||
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
|
||||
Ok(ValidationOutcome::invalid_from_message(message))
|
||||
}
|
||||
|
||||
|
||||
+72
-32
@@ -4,7 +4,7 @@ use crate::{
|
||||
config::Config,
|
||||
core::{
|
||||
handle::Handle,
|
||||
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
proxy_view::{GroupScope, 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::HashSet,
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
@@ -50,20 +50,87 @@ 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(), mihomo.get_proxy_providers(),);
|
||||
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 = proxies.stringify_err()?;
|
||||
|
||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers: providers.ok(),
|
||||
providers,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -84,6 +151,7 @@ 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(_) => {
|
||||
@@ -109,31 +177,3 @@ 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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
+158
-19
@@ -8,26 +8,81 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
|
||||
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ServiceInstallOutcome {
|
||||
Installed,
|
||||
#[cfg(any(windows, test))]
|
||||
Sidecar {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||
}
|
||||
SERVICE_MANAGER
|
||||
.handle_service_status(status)
|
||||
.await
|
||||
let result = {
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
&status,
|
||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||
) {
|
||||
manager
|
||||
.controlled_stop_core_inner()
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||
}
|
||||
SERVICE_MANAGER.handle_service_status(status).await
|
||||
};
|
||||
#[cfg(windows)]
|
||||
return finish_service_installation(result, error_code, |reason| async move {
|
||||
manager.continue_with_sidecar(Some(&reason)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
result
|
||||
.map(|()| ServiceInstallOutcome::Installed)
|
||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
async fn finish_service_installation<Fallback, FallbackFuture>(
|
||||
installation: anyhow::Result<()>,
|
||||
error_code: &str,
|
||||
fallback: Fallback,
|
||||
) -> CmdResult<ServiceInstallOutcome>
|
||||
where
|
||||
Fallback: FnOnce(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 {
|
||||
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||
}
|
||||
|
||||
@@ -40,36 +95,120 @@ pub async fn uninstall_service() -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reinstall_service() -> CmdResult {
|
||||
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn repair_service() -> CmdResult {
|
||||
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.continue_with_sidecar(None)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> bool {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::core::{
|
||||
handle,
|
||||
validate::{ValidationErrorKind, ValidationOutcome},
|
||||
@@ -11,27 +12,27 @@ pub enum ValidationNoticeTarget {
|
||||
Script,
|
||||
}
|
||||
|
||||
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str {
|
||||
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
|
||||
match kind {
|
||||
ValidationErrorKind::FileMissing => "config_validate::file_not_found",
|
||||
ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
|
||||
ValidationErrorKind::FileRead => match target {
|
||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||
_ => "config_validate::yaml_read_error",
|
||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||
_ => NoticeStatus::ConfigValidateYamlReadError,
|
||||
},
|
||||
ValidationErrorKind::YamlSyntax => match target {
|
||||
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error",
|
||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error",
|
||||
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
|
||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
|
||||
},
|
||||
ValidationErrorKind::YamlMapping => match target {
|
||||
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error",
|
||||
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error",
|
||||
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
|
||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
||||
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
|
||||
},
|
||||
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",
|
||||
ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
|
||||
ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
|
||||
ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
|
||||
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +41,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_message(status, message.to_owned());
|
||||
handle::Handle::notice(status, message.as_str());
|
||||
}
|
||||
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
|
||||
let message = outcome.to_string();
|
||||
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
|
||||
handle::Handle::notice_message("config_validate::error", message);
|
||||
handle::Handle::notice(NoticeStatus::ConfigValidateError, message.as_str());
|
||||
}
|
||||
ValidationOutcome::Valid => {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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},
|
||||
@@ -69,6 +70,12 @@ 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?;
|
||||
|
||||
@@ -106,7 +113,7 @@ impl Config {
|
||||
|
||||
if let Some((msg_type, msg_content)) = validation_result {
|
||||
sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
handle::Handle::notice_message(msg_type, msg_content);
|
||||
handle::Handle::notice(msg_type, msg_content.as_str());
|
||||
}
|
||||
|
||||
Self::runtime().await.apply();
|
||||
@@ -137,14 +144,14 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
|
||||
async fn generate_and_validate() -> Result<Option<(NoticeStatus, 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("config_validate::boot_error", &error_msg)
|
||||
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
||||
.await?;
|
||||
return Ok(Some(("config_validate::boot_error", error_msg)));
|
||||
return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
|
||||
}
|
||||
logging!(debug, Type::Config, "生成运行时配置成功");
|
||||
|
||||
@@ -171,25 +178,25 @@ impl Config {
|
||||
error_msg
|
||||
);
|
||||
CoreManager::global()
|
||||
.use_default_config("config_validate::boot_error", &error_msg)
|
||||
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
||||
.await?;
|
||||
Ok(Some(("config_validate::boot_error", error_msg)))
|
||||
Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
|
||||
CoreManager::global()
|
||||
.use_default_config("config_validate::process_terminated", "")
|
||||
.use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
|
||||
.await?;
|
||||
Ok(Some(("config_validate::process_terminated", String::new())))
|
||||
Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, 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("config_validate::error", "")
|
||||
.use_default_config(NoticeStatus::ConfigValidateError, "")
|
||||
.await?;
|
||||
Ok(Some(("config_validate::error", String::new())))
|
||||
Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-12
@@ -1,3 +1,4 @@
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use anyhow::Result;
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -108,8 +109,7 @@ impl DnsOverrideState {
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ProfileDnsSettings {
|
||||
pub enabled: bool,
|
||||
// Force-enable confirmation is valid only for the current app session.
|
||||
#[serde(skip)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub confirmation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -139,12 +139,13 @@ 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_message("dns_override::auto_disabled", "");
|
||||
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
|
||||
}
|
||||
verge.data_arc().save_file().await
|
||||
let result = verge.data_arc().save_file().await;
|
||||
Handle::refresh_verge();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +196,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
|
||||
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
|
||||
let source = Some("provider-dns".into());
|
||||
let confirmed = IVerge {
|
||||
profile_dns_settings: [(
|
||||
@@ -208,15 +209,22 @@ mod tests {
|
||||
.into(),
|
||||
..IVerge::default()
|
||||
};
|
||||
let settings = confirmed.dns_settings_for("one");
|
||||
assert!(DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation).enabled);
|
||||
for saved in [
|
||||
serde_yaml_ng::to_string(&confirmed)?,
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
let saved = serde_yaml_ng::to_string(&confirmed)?;
|
||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||
let settings = restarted.dns_settings_for("one");
|
||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
||||
assert!(state.enabled);
|
||||
assert!(!state.apply_to(&mut restarted));
|
||||
for (saved, source) in [
|
||||
(saved, Some("updated".into())),
|
||||
(
|
||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||
source,
|
||||
),
|
||||
] {
|
||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||
let settings = restarted.dns_settings_for("one");
|
||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
||||
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
|
||||
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
||||
assert!(state.apply_to(&mut restarted));
|
||||
assert!(!restarted.dns_settings_for("one").enabled);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{Config, IClashTemp, IVerge, MixedPort};
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::{
|
||||
constants::timing,
|
||||
core::{
|
||||
@@ -14,15 +15,9 @@ 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 _,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct MixedPortFallback {
|
||||
@@ -30,9 +25,8 @@ struct MixedPortFallback {
|
||||
current: u16,
|
||||
}
|
||||
|
||||
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));
|
||||
static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
|
||||
static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
impl Config {
|
||||
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
||||
@@ -147,17 +141,12 @@ 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> {
|
||||
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
|
||||
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn notify_startup_mixed_port_fallback() {
|
||||
@@ -166,8 +155,8 @@ impl Config {
|
||||
};
|
||||
AsyncHandler::spawn(move || async move {
|
||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
Handle::notice_message(
|
||||
"mixed_port::fallback",
|
||||
Handle::notice(
|
||||
NoticeStatus::MixedPortFallback,
|
||||
format!("{},{}", change.original, change.current),
|
||||
);
|
||||
});
|
||||
@@ -196,7 +185,7 @@ fn report_fallback_error(message: String) {
|
||||
);
|
||||
AsyncHandler::spawn(move || async move {
|
||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||
Handle::notice_message("mixed_port::fallback_error", message);
|
||||
Handle::notice(NoticeStatus::MixedPortFallbackError, message);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ 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>,
|
||||
|
||||
@@ -217,6 +220,7 @@ impl PrfItem {
|
||||
desc: Some(desc),
|
||||
file: Some(file),
|
||||
url: None,
|
||||
latency_test_urls: None,
|
||||
selected: None,
|
||||
extra: None,
|
||||
option: Some(PrfOption {
|
||||
@@ -396,6 +400,7 @@ impl PrfItem {
|
||||
desc: desc.cloned(),
|
||||
file: Some(file),
|
||||
url: Some(url.as_str().into()),
|
||||
latency_test_urls: None,
|
||||
selected: None,
|
||||
extra,
|
||||
option: Some(PrfOption {
|
||||
|
||||
+156
-148
@@ -2,6 +2,8 @@ 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::{
|
||||
@@ -16,6 +18,7 @@ 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},
|
||||
@@ -245,6 +248,7 @@ 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);
|
||||
@@ -600,19 +604,21 @@ fn is_activation_current(generation: u64) -> bool {
|
||||
}
|
||||
|
||||
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.context("timed out while waiting for mihomo proxies")
|
||||
.context("timed out while waiting for mihomo proxies")?
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
||||
@@ -691,51 +697,58 @@ 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);
|
||||
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(¤t)))
|
||||
else {
|
||||
return Ok((profiles, false));
|
||||
};
|
||||
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(¤t)))
|
||||
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));
|
||||
}
|
||||
entry.now = Some(node);
|
||||
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));
|
||||
}
|
||||
None => selected.push(PrfSelected {
|
||||
name: Some(group_name),
|
||||
now: Some(node),
|
||||
}),
|
||||
entry.now = Some(node);
|
||||
}
|
||||
item.selected = Some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await?;
|
||||
|
||||
if recorded {
|
||||
supersede_selected_activation();
|
||||
handle::Handle::refresh_profiles();
|
||||
}
|
||||
Ok(())
|
||||
None => selected.push(PrfSelected {
|
||||
name: Some(group_name),
|
||||
now: Some(node),
|
||||
}),
|
||||
}
|
||||
item.selected = Some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
|
||||
@@ -745,36 +758,28 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
|
||||
}
|
||||
|
||||
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
||||
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(¤t)))
|
||||
else {
|
||||
return Ok((profiles, false));
|
||||
};
|
||||
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(¤t)))
|
||||
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?;
|
||||
|
||||
if cleared {
|
||||
supersede_selected_activation();
|
||||
handle::Handle::refresh_profiles();
|
||||
}
|
||||
Ok(())
|
||||
item.selected = (!selected.is_empty()).then_some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn persist_reconciled_selected(
|
||||
@@ -787,34 +792,27 @@ 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();
|
||||
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));
|
||||
}
|
||||
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 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?;
|
||||
|
||||
if updated {
|
||||
handle::Handle::refresh_profiles();
|
||||
}
|
||||
Ok(())
|
||||
profile.selected = (!selected.is_empty()).then_some(selected);
|
||||
profiles.save_file().await?;
|
||||
Ok((profiles, true))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns recorded selections whose groups have not reached the requested node.
|
||||
@@ -836,48 +834,58 @@ 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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// Releases the first-pass waiter even when restoration exits early.
|
||||
@@ -920,7 +928,7 @@ async fn activate_selected_nodes_worker(
|
||||
}
|
||||
|
||||
if is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
announce(Refresh::Clash);
|
||||
}
|
||||
|
||||
let plan = if needs_confirmation {
|
||||
@@ -944,7 +952,7 @@ async fn activate_selected_nodes_worker(
|
||||
return Ok(());
|
||||
};
|
||||
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
announce(Refresh::Clash);
|
||||
}
|
||||
confirmed_plan
|
||||
} else {
|
||||
@@ -1034,7 +1042,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
||||
|
||||
if selected.is_empty() {
|
||||
if is_activation_current(generation) {
|
||||
handle::Handle::refresh_clash();
|
||||
announce(Refresh::Clash);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1046,7 +1054,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.
|
||||
handle::Handle::refresh_clash();
|
||||
announce(Refresh::Clash);
|
||||
}
|
||||
update_tray_after_activation(generation).await;
|
||||
logging!(debug, Type::Config, "activating selected nodes done!");
|
||||
|
||||
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
|
||||
use std::{io::ErrorKind, path::PathBuf};
|
||||
|
||||
pub(crate) struct FileSnapshot {
|
||||
path: PathBuf,
|
||||
pub(crate) path: PathBuf,
|
||||
content: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ pub mod files {
|
||||
}
|
||||
|
||||
pub mod tun {
|
||||
pub const DEFAULT_STACK: &str = "gvisor";
|
||||
pub const DEFAULT_STACK: &str = "mips";
|
||||
|
||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ use crate::{
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::OnceCell;
|
||||
use parking_lot::RwLock;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, AtomicI64, Ordering},
|
||||
},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
@@ -92,7 +91,7 @@ pub struct AutoBackupManager {
|
||||
|
||||
impl AutoBackupManager {
|
||||
pub fn global() -> &'static Self {
|
||||
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new();
|
||||
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
|
||||
INSTANCE.get_or_init(|| {
|
||||
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
|
||||
Self {
|
||||
@@ -4,7 +4,6 @@ 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::{
|
||||
@@ -12,7 +11,7 @@ use std::{
|
||||
env::{consts::OS, temp_dir},
|
||||
io::Write as _,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
sync::{Arc, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{fs, time::timeout};
|
||||
@@ -58,7 +57,7 @@ pub struct WebDavClient {
|
||||
|
||||
impl WebDavClient {
|
||||
pub fn global() -> &'static Self {
|
||||
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
|
||||
static WEBDAV_CLIENT: OnceLock<WebDavClient> = OnceLock::new();
|
||||
WEBDAV_CLIENT.get_or_init(|| Self {
|
||||
config: ArcSwapOption::new(None),
|
||||
clients: ArcSwap::new(Arc::new(HashMap::new())),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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, feat, module::lightweight::entry_lightweight_mode};
|
||||
use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_mode, feat};
|
||||
use anyhow::{Result, bail};
|
||||
use arc_swap::ArcSwap;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
@@ -177,7 +178,7 @@ impl Hotkey {
|
||||
"Hotkey profile reactivation failed validation: {}",
|
||||
message.as_str()
|
||||
);
|
||||
handle::Handle::notice_message("reactivate_profiles::error", message);
|
||||
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
|
||||
}
|
||||
Err(err) => {
|
||||
logging!(
|
||||
@@ -186,7 +187,7 @@ impl Hotkey {
|
||||
"Failed to reactivate subscriptions via hotkey: {}",
|
||||
err
|
||||
);
|
||||
handle::Handle::notice_message("reactivate_profiles::error", err.to_string());
|
||||
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,10 +271,7 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
|
||||
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||
}
|
||||
|
||||
let normalized = bind_address
|
||||
.strip_prefix('[')
|
||||
.and_then(|address| address.strip_suffix(']'))
|
||||
.unwrap_or(bind_address);
|
||||
let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
|
||||
Ok(vec![
|
||||
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
||||
])
|
||||
@@ -298,11 +295,7 @@ 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_prefix('[')
|
||||
.and_then(|host| host.strip_suffix(']'))
|
||||
.unwrap_or_else(|| host.trim());
|
||||
let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
|
||||
if host.eq_ignore_ascii_case("localhost") {
|
||||
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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},
|
||||
@@ -18,6 +19,7 @@ use smartstring::alias::String;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_mihomo::Error as MihomoError;
|
||||
@@ -129,7 +131,31 @@ where
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
|
||||
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<()> {
|
||||
use crate::constants::files::RUNTIME_CONFIG;
|
||||
|
||||
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
|
||||
@@ -145,7 +171,7 @@ impl CoreManager {
|
||||
});
|
||||
|
||||
help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?;
|
||||
handle::Handle::notice_message(error_key, error_msg);
|
||||
handle::Handle::notice(status, message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -234,6 +260,7 @@ 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}"))
|
||||
@@ -297,8 +324,27 @@ impl CoreManager {
|
||||
self.validate_and_apply(transaction).await
|
||||
}
|
||||
|
||||
/// Validates and applies the caller's transaction, committing only on success.
|
||||
/// Commits the applied runtime even if restoring the system proxy subsequently fails.
|
||||
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()
|
||||
@@ -309,11 +355,34 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
let run_path = Config::write_runtime_file(&yaml).await?;
|
||||
self.apply_config(run_path).await?;
|
||||
transaction.commit();
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
@@ -405,6 +474,31 @@ 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 {
|
||||
|
||||
@@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
|
||||
| ServiceStatus::NeedsReinstall
|
||||
| ServiceStatus::InstallRequired
|
||||
| ServiceStatus::Unavailable(_)
|
||||
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
|
||||
| ServiceStatus::SidecarAllowed
|
||||
) | (
|
||||
RunningMode::Sidecar,
|
||||
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -330,6 +334,18 @@ 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;
|
||||
@@ -360,9 +376,12 @@ 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) -> Result<()> {
|
||||
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
return Err(report_sidecar_failure(
|
||||
anyhow::anyhow!("configuration update is already running"),
|
||||
service_rejection,
|
||||
));
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
@@ -371,59 +390,68 @@ 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;
|
||||
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:?}");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
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;
|
||||
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
|
||||
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");
|
||||
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:?}");
|
||||
}
|
||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||
self.apply_proxy_after_start().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(())
|
||||
}
|
||||
.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(())
|
||||
result.map_err(|error| report_sidecar_failure(error, service_rejection))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
|
||||
@@ -489,7 +517,13 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
|
||||
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
|
||||
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
|
||||
}
|
||||
|
||||
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
|
||||
@@ -518,6 +552,8 @@ 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.
|
||||
@@ -566,6 +602,7 @@ impl CoreManager {
|
||||
}
|
||||
// tell the window when a background apply lands
|
||||
Handle::refresh_verge();
|
||||
crate::core::notification::retire_sidecar_failure();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -588,6 +625,8 @@ impl CoreManager {
|
||||
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
||||
if matches!(startup, StartupDecision::Wait) {
|
||||
self.rollback_failed_start().await;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::utils::resolve::dns::sync_public_dns().await;
|
||||
#[cfg(target_os = "windows")]
|
||||
self.try_sidecar_after_service_unavailable().await?;
|
||||
return Ok(());
|
||||
@@ -603,7 +642,11 @@ impl CoreManager {
|
||||
{
|
||||
run_service_start_with_sidecar_fallback(
|
||||
|| self.start_core_by_service(),
|
||||
clash_verge_service_ipc::execution::check_sidecar_available,
|
||||
|| 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
|
||||
@@ -622,6 +665,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -646,7 +691,10 @@ impl CoreManager {
|
||||
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 {
|
||||
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
|
||||
.await
|
||||
.map_err(super::state::explain_core_occupancy)
|
||||
{
|
||||
logging!(
|
||||
warn,
|
||||
Type::Core,
|
||||
@@ -672,7 +720,7 @@ impl CoreManager {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
self.rollback_failed_start().await;
|
||||
} else {
|
||||
crate::core::service::notify_service_fallback();
|
||||
crate::core::service::notify_service_fallback(&reason);
|
||||
}
|
||||
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
|
||||
}
|
||||
@@ -710,11 +758,17 @@ impl CoreManager {
|
||||
|
||||
async fn stop_core_unprepared_inner(&self) -> Result<()> {
|
||||
CLASH_LOGGER.clear_logs();
|
||||
match *self.get_running_mode() {
|
||||
let result = match *self.get_running_mode() {
|
||||
RunningMode::Service => self.stop_core_by_service().await,
|
||||
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
|
||||
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")]
|
||||
@@ -796,7 +850,12 @@ impl CoreManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::core::runstate::RUN_STATE.state().service_usable() {
|
||||
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))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -834,29 +893,34 @@ impl CoreManager {
|
||||
AsyncHandler::spawn(|| async move {
|
||||
let manager = Self::global();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
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 {
|
||||
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"service handoff window elapsed; staying in sidecar mode"
|
||||
);
|
||||
break;
|
||||
return Ok(());
|
||||
}
|
||||
// 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) {
|
||||
break;
|
||||
return Ok(());
|
||||
}
|
||||
match manager.try_handoff_sidecar_to_service().await {
|
||||
HandoffOutcome::Done => break,
|
||||
HandoffOutcome::Done => Ok(()),
|
||||
HandoffOutcome::Failed => {
|
||||
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
|
||||
break;
|
||||
Ok(())
|
||||
}
|
||||
HandoffOutcome::NotReady => {}
|
||||
HandoffOutcome::NotReady => Err(RetryError::Retry(())),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
manager.handoff_watcher_running.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
@@ -1474,12 +1538,18 @@ mod tests {
|
||||
ServiceStatus::NeedsReinstall,
|
||||
ServiceStatus::InstallRequired,
|
||||
ServiceStatus::Unavailable("offline".into()),
|
||||
ServiceStatus::SidecarAllowed,
|
||||
];
|
||||
for status in &allowed_statuses {
|
||||
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
|
||||
if !matches!(status, ServiceStatus::InstallRequired) {
|
||||
if !matches!(
|
||||
status,
|
||||
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
|
||||
) {
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||
} else {
|
||||
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,7 +1559,6 @@ mod tests {
|
||||
ServiceStatus::UninstallRequired,
|
||||
ServiceStatus::ReinstallRequired,
|
||||
ServiceStatus::ForceReinstallRequired,
|
||||
ServiceStatus::SidecarAllowed,
|
||||
];
|
||||
for status in &rejected_statuses {
|
||||
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||
|
||||
@@ -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,
|
||||
Arc, LazyLock,
|
||||
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: Lazy<Arc<LogRing>> = Lazy::new(|| Arc::new(LogRing::new()));
|
||||
pub(crate) static CLASH_LOGGER: LazyLock<Arc<LogRing>> = LazyLock::new(|| Arc::new(LogRing::new()));
|
||||
|
||||
tokio::task_local! {
|
||||
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
|
||||
@@ -74,6 +74,14 @@ impl fmt::Display for RunningMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the Core is not running, kept until a Core starts again.
|
||||
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
|
||||
pub enum CoreFailure {
|
||||
StartFailed(String),
|
||||
ServiceCoreStopped(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CoreManager {
|
||||
/// The Run State this manager reports transitions to.
|
||||
@@ -87,6 +95,7 @@ pub struct CoreManager {
|
||||
job_handle: ArcSwapOption<OwnedHandle>,
|
||||
config_update_in_progress: AtomicBool,
|
||||
core_readiness_state: AtomicU64,
|
||||
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
|
||||
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||
@@ -115,6 +124,7 @@ impl Default for CoreManager {
|
||||
job_handle: ArcSwapOption::new(None),
|
||||
config_update_in_progress: AtomicBool::new(false),
|
||||
core_readiness_state: AtomicU64::new(0),
|
||||
startup_error: parking_lot::Mutex::new(None),
|
||||
sidecar_exit: tokio::sync::Mutex::new(None),
|
||||
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -141,6 +151,17 @@ impl CoreManager {
|
||||
self.run_state.mode_arc()
|
||||
}
|
||||
|
||||
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
|
||||
let mut startup_error = self.startup_error.lock();
|
||||
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
|
||||
*startup_error = Some(failure);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
|
||||
self.startup_error.lock().clone()
|
||||
}
|
||||
|
||||
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
|
||||
self.state
|
||||
.load()
|
||||
@@ -166,7 +187,9 @@ impl CoreManager {
|
||||
if previous != mode {
|
||||
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
|
||||
}
|
||||
let mut startup_error = self.startup_error.lock();
|
||||
self.run_state.core_started(mode);
|
||||
*startup_error = None;
|
||||
}
|
||||
|
||||
/// The Core is no longer running, for any reason.
|
||||
@@ -214,14 +237,14 @@ impl CoreManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_core_readiness_generation(&self) -> Option<u64> {
|
||||
pub(crate) 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
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
|
||||
active_core_readiness_generation(current).map(inactive_core_readiness_state)
|
||||
});
|
||||
}
|
||||
@@ -261,49 +284,83 @@ impl CoreManager {
|
||||
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
|
||||
}
|
||||
|
||||
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) {
|
||||
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(()) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Err(start_error);
|
||||
Ok(!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) => {
|
||||
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();
|
||||
return Err(start_error);
|
||||
return Err(RetryError::Stop(start_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:#}"
|
||||
)));
|
||||
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(error) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
Err(RetryError::Stop(error))
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
crate::config::Config::notify_startup_mixed_port_fallback();
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[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,
|
||||
@@ -11,10 +12,20 @@ use crate::{
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::Type;
|
||||
use log::Level;
|
||||
use std::num::NonZeroUsize;
|
||||
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};
|
||||
@@ -41,14 +52,15 @@ async fn retry_service_start<Start, StartFuture>(
|
||||
mut start: Start,
|
||||
) -> Result<()>
|
||||
where
|
||||
Start: FnMut() -> StartFuture,
|
||||
StartFuture: std::future::Future<Output = Result<()>>,
|
||||
Start: FnMut() -> StartFuture + Send,
|
||||
StartFuture: std::future::Future<Output = Result<()>> + Send,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts {
|
||||
match start().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
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,
|
||||
@@ -60,16 +72,14 @@ where
|
||||
.downcast_ref::<service::ServiceStartRefusal>()
|
||||
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
||||
{
|
||||
return Err(error);
|
||||
RetryError::Stop(error)
|
||||
} else {
|
||||
RetryError::Retry(error)
|
||||
}
|
||||
last_error = Some(error);
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(retry_delay).await;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
@@ -95,14 +105,16 @@ async fn poll_sidecar_readiness<F, Fut>(
|
||||
mut probe: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<()>>,
|
||||
F: FnMut() -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<()>> + Send,
|
||||
{
|
||||
let mut last_error = None;
|
||||
for attempt in 0..max_attempts {
|
||||
match probe().await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => {
|
||||
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| {
|
||||
logging!(
|
||||
debug,
|
||||
Type::Core,
|
||||
@@ -110,16 +122,12 @@ where
|
||||
attempt + 1,
|
||||
max_attempts
|
||||
);
|
||||
last_error = Some(error);
|
||||
}
|
||||
RetryError::Retry(error)
|
||||
})
|
||||
}
|
||||
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"))
|
||||
})
|
||||
.await
|
||||
.context("Mihomo API did not become ready")
|
||||
}
|
||||
|
||||
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
|
||||
@@ -153,7 +161,10 @@ impl CoreManager {
|
||||
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
|
||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||
self.wait_for_sidecar_exit().await?;
|
||||
let execution = clash_verge_service_ipc::execution::reserve_sidecar().await?;
|
||||
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
|
||||
.await
|
||||
.inspect_err(service::record_residual_service)
|
||||
.map_err(explain_core_occupancy)?;
|
||||
self.core_stopped();
|
||||
|
||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||
@@ -246,7 +257,7 @@ impl CoreManager {
|
||||
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();
|
||||
let message = String::from_utf8_lossy_owned(line);
|
||||
Logger::global().writer_sidecar_log(Level::Error, &message);
|
||||
CLASH_LOGGER.append_log(message);
|
||||
}
|
||||
@@ -392,6 +403,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,6 +521,20 @@ 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));
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 notification;
|
||||
pub mod notify;
|
||||
pub(crate) mod owner_identity;
|
||||
pub mod proxy_control;
|
||||
pub mod proxy_view;
|
||||
@@ -22,3 +23,4 @@ pub mod validate;
|
||||
pub mod win_uwp;
|
||||
|
||||
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
|
||||
pub use notify::{handle, notification};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::NoticeStatus;
|
||||
use crate::{APP_HANDLE, singleton};
|
||||
use smartstring::alias::String;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||
|
||||
@@ -36,19 +40,11 @@ impl Handle {
|
||||
}
|
||||
|
||||
pub fn refresh_clash() {
|
||||
Self::send_event(FrontendEvent::RefreshClash);
|
||||
super::announce(super::Refresh::Clash);
|
||||
}
|
||||
|
||||
pub fn 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);
|
||||
super::announce(super::Refresh::Verge);
|
||||
}
|
||||
|
||||
/// Push a Run State snapshot to the frontend.
|
||||
@@ -80,13 +76,10 @@ impl Handle {
|
||||
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
|
||||
Self::send_event(FrontendEvent::NoticeMessage {
|
||||
status: status_str,
|
||||
message: msg_str,
|
||||
status,
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +95,7 @@ impl Handle {
|
||||
self.is_exiting.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn send_event(event: FrontendEvent) {
|
||||
pub(super) fn send_event(event: FrontendEvent) {
|
||||
let handle = Self::global();
|
||||
if handle.is_exiting() {
|
||||
return;
|
||||
@@ -0,0 +1,174 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
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::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::{collections::HashMap, future::Future};
|
||||
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FrontendEvent<'a> {
|
||||
RefreshClash,
|
||||
RefreshVerge,
|
||||
RefreshProfiles,
|
||||
RefreshProxyConfig,
|
||||
NoticeMessage {
|
||||
status: &'a str,
|
||||
message: String,
|
||||
status: NoticeStatus,
|
||||
message: Arc<str>,
|
||||
},
|
||||
ProfileChanged {
|
||||
current_profile_id: &'a String,
|
||||
@@ -89,16 +87,23 @@ pub struct PendingFailure {
|
||||
/// Non-destructive pending state, indexed by stable code.
|
||||
#[derive(Debug, Default)]
|
||||
struct FailureTable {
|
||||
entries: Mutex<HashMap<String, PendingFailure>>,
|
||||
sequence: AtomicU64,
|
||||
entries: Mutex<FailureEntries>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FailureEntries {
|
||||
map: HashMap<String, PendingFailure>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -106,13 +111,13 @@ impl FailureTable {
|
||||
code: code.into(),
|
||||
detail,
|
||||
operation,
|
||||
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
|
||||
sequence: entries.sequence,
|
||||
};
|
||||
entries.insert(code.into(), failure);
|
||||
entries.map.insert(code.into(), failure);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<PendingFailure> {
|
||||
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
|
||||
let mut failures: Vec<PendingFailure> = self.entries.lock().map.values().cloned().collect();
|
||||
failures.sort_by_key(|failure| failure.sequence);
|
||||
failures
|
||||
}
|
||||
@@ -120,17 +125,21 @@ impl FailureTable {
|
||||
/// Return whether a guard failure was retired.
|
||||
fn retire_guard(&self) -> bool {
|
||||
let mut entries = self.entries.lock();
|
||||
let before = entries.len();
|
||||
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
|
||||
before != entries.len()
|
||||
let before = entries.map.len();
|
||||
entries
|
||||
.map
|
||||
.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
|
||||
before != entries.map.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.len();
|
||||
entries.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
|
||||
before != entries.len()
|
||||
let before = entries.map.len();
|
||||
entries
|
||||
.map
|
||||
.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
|
||||
before != entries.map.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +238,47 @@ pub fn what_was_asked() -> FailedOperation {
|
||||
.unwrap_or(FailedOperation::SystemProxyRestore)
|
||||
}
|
||||
|
||||
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
||||
PENDING_FAILURES.record(operation, code, detail.into());
|
||||
@@ -237,7 +286,7 @@ pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<
|
||||
}
|
||||
|
||||
pub fn has_pending_failure(code: &str) -> bool {
|
||||
PENDING_FAILURES.entries.lock().contains_key(code)
|
||||
PENDING_FAILURES.entries.lock().map.contains_key(code)
|
||||
}
|
||||
|
||||
/// Return unresolved failures oldest first without clearing them.
|
||||
@@ -283,9 +332,10 @@ 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)))
|
||||
}
|
||||
FrontendEvent::NoticeMessage { status, message } => (
|
||||
"verge://notice-message",
|
||||
serde_json::to_value((status, message.as_ref())),
|
||||
),
|
||||
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 }))),
|
||||
@@ -313,3 +363,106 @@ 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 })
|
||||
}
|
||||
@@ -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_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_private_file(path)
|
||||
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_lock_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_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
||||
GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
@@ -215,22 +215,49 @@ mod windows_owner {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
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 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");
|
||||
}
|
||||
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> {
|
||||
@@ -442,7 +469,8 @@ mod windows_owner {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A DACL that refuses even the owner read settles it just as well.
|
||||
// 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),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !ours {
|
||||
@@ -712,7 +740,7 @@ mod windows_owner {
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
bail!("failed to restrict owner token DACL: Windows error {status}");
|
||||
bail!("failed to restrict current-user file DACL: Windows error {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -522,25 +522,34 @@ 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<()> {
|
||||
for attempt in 1..CLEAR_ATTEMPTS {
|
||||
match clear_inner().await {
|
||||
Err(error)
|
||||
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| {
|
||||
if matches!(
|
||||
SysproxyFailure::from_chain(&error),
|
||||
Some(SysproxyFailure::SystemCallFailed)
|
||||
) =>
|
||||
{
|
||||
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
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
|
||||
|
||||
+121
-309
@@ -1,16 +1,23 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_mihomo::models::{
|
||||
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
|
||||
DelayHistory, Extra, 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)]
|
||||
@@ -68,6 +75,7 @@ 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>,
|
||||
@@ -82,6 +90,7 @@ 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")]
|
||||
@@ -167,15 +176,41 @@ 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 BTreeMap<String, Vec<String>>,
|
||||
provider_candidates: &'a ProviderCandidates,
|
||||
provider_available: bool,
|
||||
}
|
||||
|
||||
impl MemberResolver<'_> {
|
||||
fn resolve(&self, name: String) -> ProxyMemberRef {
|
||||
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 {
|
||||
if self.group_names.contains(&name) {
|
||||
ProxyMemberRef::Group { name }
|
||||
} else if let Some(record_id) = self.core_node_ids.get(&name) {
|
||||
@@ -189,16 +224,17 @@ impl MemberResolver<'_> {
|
||||
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
|
||||
}
|
||||
} else {
|
||||
match self.provider_candidates.get(&name).map(Vec::as_slice) {
|
||||
Some([record_id]) => ProxyMemberRef::Node {
|
||||
let candidates = self.provider_candidates.get(&name).map_or(&[][..], Vec::as_slice);
|
||||
match (scoped, candidates) {
|
||||
(Some(record_id), _) | (None, [(_, record_id)]) => ProxyMemberRef::Node {
|
||||
name,
|
||||
record_id: record_id.clone(),
|
||||
},
|
||||
None => ProxyMemberRef::Unresolved {
|
||||
(None, []) => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Missing,
|
||||
},
|
||||
Some(_) => ProxyMemberRef::Unresolved {
|
||||
(None, _) => ProxyMemberRef::Unresolved {
|
||||
name,
|
||||
reason: ProxyMemberUnresolvedReason::Ambiguous,
|
||||
},
|
||||
@@ -211,6 +247,7 @@ impl ProxyViewBuilder {
|
||||
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
|
||||
let ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers,
|
||||
} = input;
|
||||
@@ -219,11 +256,13 @@ impl ProxyViewBuilder {
|
||||
} else {
|
||||
ProxyViewProviderState::Unavailable
|
||||
};
|
||||
let (mut core_groups, core_nodes) = partition_core(proxies);
|
||||
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
|
||||
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,
|
||||
@@ -250,14 +289,14 @@ impl ProxyViewBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
|
||||
fn partition_core(proxies: Proxies, providers_available: bool) -> (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 {
|
||||
} else if !providers_available || proxy.provider_name.is_empty() {
|
||||
nodes.insert(name, proxy);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +330,7 @@ fn build_core_records(
|
||||
fn build_provider_records(
|
||||
providers: Option<ProxyProviders>,
|
||||
records: &mut BTreeMap<String, ProxyNodeView>,
|
||||
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
|
||||
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
|
||||
let providers = providers
|
||||
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
||||
.unwrap_or_default();
|
||||
@@ -322,7 +361,7 @@ fn build_provider_records(
|
||||
candidates
|
||||
.entry(proxy_name.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(record_id.clone());
|
||||
.push((provider_name.clone(), record_id.clone()));
|
||||
records.insert(
|
||||
record_id.clone(),
|
||||
build_node(
|
||||
@@ -365,6 +404,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -373,6 +413,20 @@ 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,
|
||||
@@ -384,6 +438,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
icon,
|
||||
test_url,
|
||||
history,
|
||||
extra,
|
||||
capabilities: ProxyCapabilities {
|
||||
udp,
|
||||
xudp,
|
||||
@@ -391,11 +446,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
|
||||
mptcp,
|
||||
smux,
|
||||
},
|
||||
members: all
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|name| resolver.resolve(name))
|
||||
.collect(),
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +458,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
test_url,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
udp,
|
||||
xudp,
|
||||
tfo,
|
||||
@@ -422,6 +474,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
|
||||
proxy_type,
|
||||
alive,
|
||||
history,
|
||||
extra,
|
||||
id,
|
||||
hidden,
|
||||
icon,
|
||||
@@ -477,301 +530,60 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
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()
|
||||
#[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 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 },
|
||||
#[test]
|
||||
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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![],
|
||||
proxies: Proxies { proxies },
|
||||
providers: Some(providers),
|
||||
});
|
||||
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());
|
||||
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@ pub struct RealEnv;
|
||||
|
||||
impl RunStateEnv for RealEnv {
|
||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||
// The IPC client would otherwise back off for about two minutes before failing.
|
||||
#[cfg(windows)]
|
||||
if 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| {
|
||||
@@ -62,9 +70,17 @@ impl RunStateEnv for RealEnv {
|
||||
}
|
||||
|
||||
async fn trusted_install_evidence(&self) -> Result<bool> {
|
||||
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
|
||||
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
|
||||
.await
|
||||
.context("service registration probe did not finish")?
|
||||
.context("service registration probe did not finish")??;
|
||||
// A helper that outlived its registration is a broken Service, not an absent one.
|
||||
#[cfg(unix)]
|
||||
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
|
||||
return Ok(error
|
||||
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
|
||||
.is_some());
|
||||
}
|
||||
Ok(registered)
|
||||
}
|
||||
|
||||
fn is_elevated(&self) -> bool {
|
||||
|
||||
@@ -10,7 +10,7 @@ mod probe;
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
@@ -19,7 +19,6 @@ 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;
|
||||
|
||||
@@ -28,14 +27,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::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
|
||||
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
|
||||
|
||||
use crate::core::manager::RunningMode;
|
||||
use health::StoredService;
|
||||
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
|
||||
|
||||
/// The process-wide Run State, observing the real machine.
|
||||
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
|
||||
pub static RUN_STATE: LazyLock<RunStateStore<RealEnv>> = LazyLock::new(|| RunStateStore::new(RealEnv));
|
||||
|
||||
/// Distinguishes silence from a reply that updated Service health with a rejection.
|
||||
#[derive(Debug)]
|
||||
@@ -130,20 +129,21 @@ 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> {
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts {
|
||||
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 {
|
||||
match self.env.probe_service_version().await {
|
||||
Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected),
|
||||
Err(error) => last_error = Some(error),
|
||||
Ok(reply) => self
|
||||
.record_reply(&reply)
|
||||
.map_err(|error| RetryError::Stop(ReadyWaitError::Rejected(error))),
|
||||
Err(error) => Err(RetryError::Retry(ReadyWaitError::Unreachable(error))),
|
||||
}
|
||||
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")
|
||||
})))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
|
||||
|
||||
@@ -27,6 +27,9 @@ pub enum ServiceVersionCheck {
|
||||
CoreUnavailable(String),
|
||||
}
|
||||
|
||||
/// Leads the Unavailable reason when the Service refused the approved core itself.
|
||||
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
|
||||
|
||||
/// What a live probe of the currently-installed Service told us.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CurrentServiceProbe {
|
||||
@@ -47,9 +50,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
|
||||
return match &reply.core {
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
|
||||
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
|
||||
ServiceVersionCheck::CoreUnavailable(format!(
|
||||
"approved core was rejected: {reason}; reinstall the service to repair it"
|
||||
))
|
||||
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
|
||||
}
|
||||
_ => ServiceVersionCheck::CoreUnavailable(
|
||||
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
|
||||
|
||||
+312
-127
@@ -1,3 +1,4 @@
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::utils::dirs;
|
||||
use crate::{
|
||||
config::{Config, runtime::IRuntime},
|
||||
@@ -5,12 +6,12 @@ use crate::{
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::Handle,
|
||||
manager::RunningMode,
|
||||
manager::{CoreFailure, RunningMode},
|
||||
owner_identity::current_owner_credentials,
|
||||
proxy_control,
|
||||
runstate::{
|
||||
OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError,
|
||||
RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
||||
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||
},
|
||||
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
||||
tray::Tray,
|
||||
@@ -21,10 +22,10 @@ use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::Draft;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::{
|
||||
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
|
||||
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
|
||||
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
||||
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||
};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -37,24 +38,62 @@ use std::{
|
||||
};
|
||||
|
||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
|
||||
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
|
||||
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback() {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||
/// Why the Service was unavailable when the core fell back to Sidecar.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
|
||||
pub enum ServiceFallbackNotice {
|
||||
Unavailable,
|
||||
CoreRejected(String),
|
||||
NotAutoStarted,
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> bool {
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
|
||||
/// The release installer registers AutoStart.
|
||||
const SERVICE_NOT_AUTO_STARTED: &str = "the Windows service is stopped and no longer starts with Windows";
|
||||
|
||||
pub(crate) fn is_not_auto_started(reason: &str) -> bool {
|
||||
// Detection prefixes its own context to the reason.
|
||||
reason.contains(SERVICE_NOT_AUTO_STARTED)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn notify_service_fallback(reason: &str) {
|
||||
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
|
||||
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
||||
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
||||
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
||||
ServiceFallbackNotice::CoreRejected(reason)
|
||||
} else if is_not_auto_started(&reason) {
|
||||
ServiceFallbackNotice::NotAutoStarted
|
||||
} else {
|
||||
ServiceFallbackNotice::Unavailable
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn take_service_repair_notice() -> bool {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns the command that gives the app data root back to this account.
|
||||
pub(crate) fn take_service_owner_notice() -> Option<String> {
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take()
|
||||
}
|
||||
|
||||
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
|
||||
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
|
||||
return None;
|
||||
};
|
||||
let path = credentials.app_data_dir.replace('\'', r"'\''");
|
||||
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
|
||||
}
|
||||
|
||||
/// Capabilities of the service session that owns the running Core.
|
||||
/// They are discarded with that session rather than cached across service upgrades.
|
||||
#[derive(Clone)]
|
||||
@@ -227,7 +266,7 @@ fn macos_service_install_marker_exists() -> std::io::Result<bool> {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
|
||||
use windows_service::{
|
||||
Error as WindowsServiceError,
|
||||
service::ServiceAccess,
|
||||
@@ -238,19 +277,51 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||
match manager.open_service(
|
||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||
ServiceAccess::QUERY_STATUS,
|
||||
ServiceAccess::QUERY_STATUS | ServiceAccess::QUERY_CONFIG,
|
||||
) {
|
||||
Ok(service) => {
|
||||
drop(service);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(service) => Ok(Some(service)),
|
||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
||||
Ok(false)
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error).context("failed to inspect Windows service registration"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
Ok(open_registered_service()?.is_some())
|
||||
}
|
||||
|
||||
/// Why IPC cannot succeed until the service is started again, if it cannot. A starting service,
|
||||
/// or an AutoStart one the SCM has yet to start this boot, is left to the IPC retries.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn service_stop_reason() -> Result<Option<&'static str>> {
|
||||
use windows_service::service::{ServiceExitCode, ServiceStartType, ServiceState};
|
||||
|
||||
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||
const NOT_RUNNING: &str = "the Windows service is not running";
|
||||
let Some(service) = open_registered_service()? else {
|
||||
return Ok(Some(NOT_RUNNING));
|
||||
};
|
||||
let status = service
|
||||
.query_status()
|
||||
.context("failed to query Windows service status")?;
|
||||
if status.current_state != ServiceState::Stopped {
|
||||
return Ok(None);
|
||||
}
|
||||
// The development channel registers an on-demand start.
|
||||
if !cfg!(feature = "verge-dev")
|
||||
&& service
|
||||
.query_config()
|
||||
.context("failed to query Windows service configuration")?
|
||||
.start_type
|
||||
!= ServiceStartType::AutoStart
|
||||
{
|
||||
return Ok(Some(SERVICE_NOT_AUTO_STARTED));
|
||||
}
|
||||
Ok((status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED)).then_some(NOT_RUNNING))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
|
||||
@@ -280,35 +351,40 @@ static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
|
||||
fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> {
|
||||
for _ in 0..32 {
|
||||
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary_name = format!(
|
||||
".{}.{}.{generation}.tmp",
|
||||
core_name.to_string_lossy(),
|
||||
std::process::id()
|
||||
);
|
||||
let temporary_path = directory.join(temporary_name);
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary_path)
|
||||
{
|
||||
Ok(file) => return Ok((temporary_path, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| {
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||
retry_sync(
|
||||
RetryPolicy::fixed(
|
||||
std::num::NonZeroUsize::MIN.saturating_add(31),
|
||||
std::time::Duration::ZERO,
|
||||
),
|
||||
|_| {
|
||||
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary_name = format!(
|
||||
".{}.{}.{generation}.tmp",
|
||||
core_name.to_string_lossy(),
|
||||
std::process::id()
|
||||
);
|
||||
let temporary_path = directory.join(temporary_name);
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary_path)
|
||||
{
|
||||
Ok(file) => Ok((temporary_path, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
Err(RetryError::Retry(anyhow::anyhow!(
|
||||
"failed to create a unique temporary development Service core in {}",
|
||||
directory.display()
|
||||
)))
|
||||
}
|
||||
Err(error) => Err(RetryError::Stop(anyhow::Error::from(error).context({
|
||||
format!(
|
||||
"failed to create temporary development Service core {}",
|
||||
temporary_path.display()
|
||||
)
|
||||
});
|
||||
}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"failed to create a unique temporary development Service core in {}",
|
||||
directory.display()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -799,12 +875,22 @@ fn record_service_start_refusal<E: RunStateEnv>(
|
||||
store: &RunStateStore<E>,
|
||||
refusal: ServiceStartRefusal,
|
||||
) -> anyhow::Error {
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning {
|
||||
// A proxy clear failure is about the system network settings; repairing the service cannot fix it.
|
||||
if store.state().mode == crate::core::manager::RunningMode::NotRunning
|
||||
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16
|
||||
{
|
||||
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
|
||||
}
|
||||
refusal.into()
|
||||
}
|
||||
|
||||
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
|
||||
pub(super) fn record_residual_service(error: &anyhow::Error) {
|
||||
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
|
||||
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试使用服务启动core
|
||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||
@@ -842,7 +928,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
#[cfg(target_os = "windows")]
|
||||
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
|
||||
Handle::notice_message("service_core::repair_required", "");
|
||||
Handle::notice(NoticeStatus::ServiceCoreRepairRequired, "");
|
||||
}
|
||||
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
||||
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
||||
Handle::notice(NoticeStatus::ServiceCoreAppDataNotOwned, "");
|
||||
}
|
||||
start_owner_monitor();
|
||||
return Err(record_service_start_refusal(
|
||||
@@ -871,8 +961,9 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||
start_owner_monitor();
|
||||
tracing::Span::current().record("outcome", "started");
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
|
||||
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
|
||||
PENDING_SERVICE_OWNER_NOTICE.lock().take();
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
@@ -915,7 +1006,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
|
||||
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
}
|
||||
let err_msg = response.message;
|
||||
bail!(err_msg);
|
||||
@@ -933,13 +1024,13 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
|
||||
let response = response.context("无法连接到Clash Verge Service")?;
|
||||
if response.code > 0 {
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
|
||||
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
|
||||
}
|
||||
bail!(response.message);
|
||||
}
|
||||
let encoded = response.data.context("服务未返回核心日志快照")?;
|
||||
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
|
||||
Ok(String::from_utf8_lossy(&content).into_owned())
|
||||
Ok(String::from_utf8_lossy_owned(content))
|
||||
}
|
||||
|
||||
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
|
||||
@@ -953,7 +1044,8 @@ fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
static PROVIDER_SYNC_QUEUED: AtomicBool = AtomicBool::new(false);
|
||||
static PROVIDER_SYNC_SERIAL: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));
|
||||
// Serializing the whole read-and-publish pass prevents an older cache from replacing a newer one.
|
||||
static PROVIDER_SYNC_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
static SYNC_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
const RUNTIME_PROVIDER_SYNC_ATTEMPTS: u32 = 4;
|
||||
const CONTENT_COMPARE_CHUNK: usize = 64 * 1024;
|
||||
@@ -964,38 +1056,47 @@ pub(crate) fn request_runtime_provider_sync(delay: Duration) {
|
||||
}
|
||||
AsyncHandler::spawn(move || async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
for attempt in 1..=RUNTIME_PROVIDER_SYNC_ATTEMPTS {
|
||||
let outcome = {
|
||||
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
|
||||
if attempt == 1 {
|
||||
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||
let _ = retry(
|
||||
RetryPolicy::fixed(
|
||||
std::num::NonZeroUsize::MIN.saturating_add((RUNTIME_PROVIDER_SYNC_ATTEMPTS - 1) as usize),
|
||||
constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY,
|
||||
),
|
||||
|index| async move {
|
||||
let attempt = index as u32 + 1;
|
||||
let outcome = {
|
||||
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
|
||||
if attempt == 1 {
|
||||
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
|
||||
}
|
||||
sync_runtime_providers_by_service().await
|
||||
};
|
||||
match outcome {
|
||||
Ok(ProviderSync { pending: 0, .. }) => Ok(()),
|
||||
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
"{pending} provider caches are not ready yet; retrying"
|
||||
);
|
||||
Err(RetryError::Retry(()))
|
||||
}
|
||||
Ok(ProviderSync { pending, .. }) => {
|
||||
logging!(warn, Type::Service, "{pending} provider caches were not synced");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"failed to sync provider caches from the service: {error:#}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
sync_runtime_providers_by_service().await
|
||||
};
|
||||
match outcome {
|
||||
Ok(ProviderSync { pending: 0, .. }) => return,
|
||||
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
"{pending} provider caches are not ready yet; retrying"
|
||||
);
|
||||
}
|
||||
Ok(ProviderSync { pending, .. }) => {
|
||||
logging!(warn, Type::Service, "{pending} provider caches were not synced");
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"failed to sync provider caches from the service: {error:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY).await;
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1303,21 +1404,29 @@ async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_default();
|
||||
for _ in 0..8 {
|
||||
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
|
||||
match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temp)
|
||||
.await
|
||||
{
|
||||
Ok(file) => return Ok((temp, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error).with_context(|| format!("failed to create {}", temp.display())),
|
||||
}
|
||||
}
|
||||
bail!("no free temporary name beside {}", target.display())
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||
retry(
|
||||
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(7), std::time::Duration::ZERO),
|
||||
|_| async {
|
||||
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
|
||||
match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temp)
|
||||
.await
|
||||
{
|
||||
Ok(file) => Ok((temp, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
|
||||
anyhow::anyhow!("no free temporary name beside {}", target.display()),
|
||||
)),
|
||||
Err(error) => Err(RetryError::Stop(
|
||||
anyhow::anyhow!(error).context(format!("failed to create {}", temp.display())),
|
||||
)),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn same_contents(temp: &Path, target: &Path) -> bool {
|
||||
@@ -1466,9 +1575,11 @@ struct OwnerRecoveryPolicy {
|
||||
reset_system_proxy: bool,
|
||||
}
|
||||
|
||||
const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
/// The macOS proxy is machine-wide and only the helper may write it, for the session it still
|
||||
/// honours; a displaced or unreachable owner must leave it alone.
|
||||
const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
|
||||
OwnerRecoveryPolicy {
|
||||
reset_system_proxy: !is_macos,
|
||||
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1601,7 @@ fn start_owner_monitor() {
|
||||
AsyncHandler::spawn(move || async move {
|
||||
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
|
||||
let mut watch = OwnerWatch::new();
|
||||
let mut core_restarts = None;
|
||||
loop {
|
||||
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
||||
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
||||
@@ -1509,7 +1621,10 @@ fn start_owner_monitor() {
|
||||
break;
|
||||
}
|
||||
|
||||
let sample = read_owner_sample().await;
|
||||
let (sample, status) = read_owner_sample().await;
|
||||
if let Some(status) = &status {
|
||||
log_core_restarts(&mut core_restarts, status);
|
||||
}
|
||||
let mut step = watch.observe(sample);
|
||||
if matches!(step, OwnerStep::VerifyTransport) {
|
||||
if watch.just_became_sustained() {
|
||||
@@ -1525,15 +1640,41 @@ fn start_owner_monitor() {
|
||||
}
|
||||
|
||||
if let OwnerStep::Recover(reason) = step {
|
||||
recover_after_owner_loss(generation, reason).await;
|
||||
recover_after_owner_loss(generation, reason, status.as_ref()).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Service logs its watchdog restarts where the app cannot read them.
|
||||
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
|
||||
if seen.is_some_and(|seen| status.restart_count > seen) {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"service restarted the core ({} restarts so far); last exit: {}",
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
*seen = Some(status.restart_count);
|
||||
}
|
||||
|
||||
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
|
||||
let detail = format!(
|
||||
"service state {:?}, {} restarts, last exit: {}",
|
||||
status.service_state,
|
||||
status.restart_count,
|
||||
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
|
||||
);
|
||||
logging!(error, Type::Service, "service core stopped: {detail}");
|
||||
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
||||
Handle::notice(NoticeStatus::CoreStartError, "");
|
||||
}
|
||||
|
||||
/// Samples ownership, treating every unusable reply as unreadable.
|
||||
async fn read_owner_sample() -> OwnerSample {
|
||||
async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
|
||||
let response = match current_owner_credentials() {
|
||||
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
|
||||
Err(error) => Err(error),
|
||||
@@ -1543,12 +1684,12 @@ async fn read_owner_sample() -> OwnerSample {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
logging!(debug, Type::Service, "service owner status was unreadable: {error:#}");
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
}
|
||||
};
|
||||
|
||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||
return OwnerSample::NotActive;
|
||||
return (OwnerSample::NotActive, None);
|
||||
}
|
||||
if response.code != 0 {
|
||||
logging!(
|
||||
@@ -1558,24 +1699,25 @@ async fn read_owner_sample() -> OwnerSample {
|
||||
response.code,
|
||||
response.message
|
||||
);
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
}
|
||||
let Some(status) = response.data else {
|
||||
logging!(debug, Type::Service, "service owner status omitted data");
|
||||
return OwnerSample::Unreadable;
|
||||
return (OwnerSample::Unreadable, None);
|
||||
};
|
||||
|
||||
// A session that no longer matches is another owner's, whatever the flags say.
|
||||
if !session_matches_active_status(status.is_active, status.active_generation) {
|
||||
return OwnerSample::NotActive;
|
||||
return (OwnerSample::NotActive, None);
|
||||
}
|
||||
|
||||
OwnerSample::Status {
|
||||
let sample = OwnerSample::Status {
|
||||
is_active: status.is_active,
|
||||
desired_core_should_be_running: status.desired_core_should_be_running,
|
||||
service_state: status.service_state,
|
||||
core_pid: status.core_pid,
|
||||
}
|
||||
};
|
||||
(sample, Some(status))
|
||||
}
|
||||
|
||||
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
|
||||
@@ -1593,7 +1735,12 @@ pub(crate) fn owner_monitor_generation() -> u64 {
|
||||
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) {
|
||||
/// `status` is the sample that led to the recovery, if any; a failed core is reported from it.
|
||||
async fn recover_after_owner_loss(
|
||||
generation: u64,
|
||||
reason: OwnerRecoveryReason,
|
||||
status: Option<&ServiceStatusSnapshot>,
|
||||
) {
|
||||
let manager = CoreManager::global();
|
||||
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
|
||||
return;
|
||||
@@ -1609,6 +1756,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason)
|
||||
return;
|
||||
}
|
||||
recover_after_owner_loss_while_locked(reason).await;
|
||||
// Still under the lifecycle lock, so a later start is the one that clears it.
|
||||
if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) {
|
||||
report_service_core_stopped(status);
|
||||
}
|
||||
}
|
||||
|
||||
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
|
||||
@@ -1633,29 +1784,43 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
||||
);
|
||||
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
||||
proxy_control::stop_guard().await;
|
||||
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
|
||||
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
||||
if policy.reset_system_proxy {
|
||||
clear_proxy_after_owner_loss().await;
|
||||
}
|
||||
clear_active_service_session();
|
||||
CoreManager::global().core_stopped();
|
||||
|
||||
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||
return;
|
||||
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
|
||||
#[cfg(target_os = "macos")]
|
||||
if policy.reset_system_proxy {
|
||||
crate::utils::resolve::dns::sync_public_dns().await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for attempt in 1..=3 {
|
||||
match proxy_control::clear().await {
|
||||
Ok(()) => return,
|
||||
Err(error) => {
|
||||
async fn clear_proxy_after_owner_loss() {
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||
let result = retry(
|
||||
RetryPolicy::fixed(
|
||||
std::num::NonZeroUsize::MIN.saturating_add(2),
|
||||
Duration::from_millis(100),
|
||||
),
|
||||
|index| async move {
|
||||
let attempt = index + 1;
|
||||
proxy_control::clear().await.map_err(|error| {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
|
||||
);
|
||||
last_error = Some(error);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = last_error {
|
||||
RetryError::Retry(error)
|
||||
})
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
// The existing recovery path also waits after its final failed clear.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
logging!(
|
||||
error,
|
||||
Type::Service,
|
||||
@@ -2155,13 +2320,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_recovery_never_resets_machine_wide_proxy() {
|
||||
fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() {
|
||||
for reason in [
|
||||
OwnerRecoveryReason::Displaced,
|
||||
OwnerRecoveryReason::SameOwnerFailure,
|
||||
OwnerRecoveryReason::TransportFailure,
|
||||
] {
|
||||
assert!(!owner_recovery_policy(reason, true).reset_system_proxy);
|
||||
assert_eq!(
|
||||
owner_recovery_policy(reason, true).reset_system_proxy,
|
||||
reason == OwnerRecoveryReason::SameOwnerFailure
|
||||
);
|
||||
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
|
||||
}
|
||||
|
||||
@@ -2219,6 +2387,23 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_clear_refusal_does_not_ask_for_service_repair() {
|
||||
let store = fake_store();
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let _ = super::record_service_start_refusal(
|
||||
&store,
|
||||
super::ServiceStartRefusal {
|
||||
code: clash_verge_service_ipc::ServiceErrorCode::ProxyClearFailed as u16,
|
||||
core_path: "/development/service-core/verge-mihomo".into(),
|
||||
message: "SystemConfiguration operation failed: lock preferences (status 3002)".into(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(store.state().service_usable());
|
||||
assert!(!store.state().service_needs_attention());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
||||
let store = fake_store();
|
||||
|
||||
@@ -477,7 +477,9 @@ 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),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ define_menu! {
|
||||
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
|
||||
profiles => PROFILES, "tray_profiles", "tray.profiles",
|
||||
proxies => PROXIES, "tray_proxies", "tray.proxies",
|
||||
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
|
||||
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
|
||||
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
|
||||
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
|
||||
|
||||
+360
-141
@@ -1,20 +1,20 @@
|
||||
use crate::config::{IProfilePreview, IVerge};
|
||||
use crate::core::lightweight;
|
||||
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
|
||||
use crate::core::tray::menu_def::TrayAction;
|
||||
use crate::module::lightweight;
|
||||
use crate::process::AsyncHandler;
|
||||
use crate::singleton;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
Type, cmd,
|
||||
config::Config,
|
||||
core::lightweight::is_in_lightweight_mode,
|
||||
feat, logging,
|
||||
module::lightweight::is_in_lightweight_mode,
|
||||
utils::{dirs::find_target_icons, help},
|
||||
};
|
||||
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
|
||||
use clash_verge_logging::logging_error;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri_plugin_mihomo::models::Proxies;
|
||||
use tokio::fs;
|
||||
|
||||
use super::handle;
|
||||
@@ -28,6 +28,7 @@ use tauri::{
|
||||
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
|
||||
};
|
||||
|
||||
mod latency;
|
||||
mod menu_def;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod speed_task;
|
||||
@@ -55,8 +56,25 @@ enum IconKind {
|
||||
Tun,
|
||||
}
|
||||
|
||||
struct LatencyMenuItem {
|
||||
item: MenuItem<Wry>,
|
||||
running: bool,
|
||||
proxies: Vec<ProxyLatencyItem>,
|
||||
provider_state: ProxyViewProviderState,
|
||||
}
|
||||
|
||||
struct ProxyLatencyItem {
|
||||
item: CheckMenuItem<Wry>,
|
||||
name: std::string::String,
|
||||
provider_name: Option<std::string::String>,
|
||||
text: std::string::String,
|
||||
}
|
||||
|
||||
pub struct Tray {
|
||||
limiter: SystemLimiter,
|
||||
proxy_hover_limiter: SystemLimiter,
|
||||
menu_update: tokio::sync::Mutex<()>,
|
||||
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
speed_controller: speed_task::TraySpeedController,
|
||||
}
|
||||
@@ -130,6 +148,9 @@ impl Default for Tray {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
|
||||
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
|
||||
menu_update: tokio::sync::Mutex::new(()),
|
||||
latency_items: parking_lot::Mutex::new(HashMap::new()),
|
||||
#[cfg(target_os = "macos")]
|
||||
speed_controller: speed_task::TraySpeedController::new(),
|
||||
}
|
||||
@@ -195,6 +216,7 @@ impl Tray {
|
||||
}
|
||||
|
||||
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
|
||||
let _update = self.menu_update.lock().await;
|
||||
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
|
||||
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
|
||||
return Ok(());
|
||||
@@ -219,29 +241,133 @@ impl Tray {
|
||||
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
|
||||
let is_lightweight_mode = is_in_lightweight_mode();
|
||||
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
tray.set_menu(Some(
|
||||
create_tray_menu(
|
||||
app_handle,
|
||||
Some(mode.as_str()),
|
||||
*system_proxy,
|
||||
*tun_mode,
|
||||
tun_mode_available,
|
||||
profiles_preview,
|
||||
TrayMenuOptions {
|
||||
is_lightweight_mode,
|
||||
include_proxy_groups,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
);
|
||||
let (menu, latency_items) = create_tray_menu(
|
||||
app_handle,
|
||||
Some(mode.as_str()),
|
||||
*system_proxy,
|
||||
*tun_mode,
|
||||
tun_mode_available,
|
||||
profiles_preview,
|
||||
TrayMenuOptions {
|
||||
is_lightweight_mode,
|
||||
include_proxy_groups,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
tray.set_menu(Some(menu))?;
|
||||
let groups: Vec<_> = latency_items.keys().cloned().collect();
|
||||
*self.latency_items.lock() = latency_items;
|
||||
for group in groups {
|
||||
self.refresh_latency_item(&group);
|
||||
}
|
||||
|
||||
logging!(debug, Type::Tray, "托盘菜单更新成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_proxy_items(&'static self) {
|
||||
let Ok(update) = self.menu_update.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
|
||||
return;
|
||||
}
|
||||
let Ok(Ok(view)) = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.latency_items
|
||||
.lock()
|
||||
.values()
|
||||
.any(|entry| entry.provider_state != view.provider_state)
|
||||
{
|
||||
// Provider fallback changes member identities; rebuild through the existing queue.
|
||||
drop(update);
|
||||
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
|
||||
return;
|
||||
}
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let profiles = Config::profiles().await.latest_arc();
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||
// Keep rebuilding serialized until these handles have received their snapshot.
|
||||
let _update = update;
|
||||
let mut items = self.latency_items.lock();
|
||||
for (group_name, entry) in items.iter_mut() {
|
||||
let Some(group) = find_group(&view, group_name) else {
|
||||
continue;
|
||||
};
|
||||
let url = latency::test_url(group, &verge, &profiles);
|
||||
let mut members = HashMap::with_capacity(group.members.len());
|
||||
for member in &group.members {
|
||||
members
|
||||
.entry((member_name(member), member_provider(&view, member)))
|
||||
.or_insert(member);
|
||||
}
|
||||
for proxy in &mut entry.proxies {
|
||||
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
|
||||
continue;
|
||||
};
|
||||
let text = proxy_item_text(&view, member, &url, &verge);
|
||||
let checked = group.now.as_ref() == Some(&proxy.name);
|
||||
if text != proxy.text {
|
||||
match proxy.item.set_text(&text) {
|
||||
Ok(()) => proxy.text = text,
|
||||
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
|
||||
}
|
||||
}
|
||||
match proxy.item.is_checked() {
|
||||
Ok(current) if current != checked => {
|
||||
logging_error!(Type::Tray, proxy.item.set_checked(checked))
|
||||
}
|
||||
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_latency_item(&self, group_name: &str) {
|
||||
let group_name = group_name.to_owned();
|
||||
logging_error!(
|
||||
Type::Tray,
|
||||
handle::Handle::app_handle().run_on_main_thread(move || {
|
||||
Self::global().refresh_latency_item_on_main_thread(&group_name);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
|
||||
let running = latency::is_running(group_name);
|
||||
let item = {
|
||||
let mut items = self.latency_items.lock();
|
||||
items.get_mut(group_name).and_then(|entry| {
|
||||
if entry.running == running {
|
||||
return None;
|
||||
}
|
||||
entry.running = running;
|
||||
Some(entry.item.clone())
|
||||
})
|
||||
};
|
||||
if let Some(item) = item {
|
||||
let label = if running {
|
||||
clash_verge_i18n::t!("tray.testingLatency")
|
||||
} else {
|
||||
clash_verge_i18n::t!("tray.testLatency")
|
||||
};
|
||||
logging_error!(Type::Tray, item.set_text(label));
|
||||
logging_error!(Type::Tray, item.set_enabled(!running));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
|
||||
if handle::Handle::global().is_exiting() {
|
||||
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
|
||||
@@ -457,96 +583,175 @@ fn create_profile_menu_item(
|
||||
.collect()
|
||||
}
|
||||
|
||||
const UNTESTED_DELAY_TEXT: &str = "-ms";
|
||||
const TIMEOUT_DELAY_TEXT: &str = "T/O";
|
||||
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
|
||||
|
||||
const fn member_name(member: &ProxyMemberRef) -> &str {
|
||||
match member {
|
||||
ProxyMemberRef::Node { name, .. }
|
||||
| ProxyMemberRef::Group { name }
|
||||
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
|
||||
view.groups
|
||||
.iter()
|
||||
.chain(view.global.iter())
|
||||
.find(|group| group.name.as_str() == name)
|
||||
}
|
||||
|
||||
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
|
||||
/// node the core tested on its own still shows a value.
|
||||
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
|
||||
view.records
|
||||
.get(record_id)
|
||||
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
|
||||
.and_then(|history| history.last())
|
||||
.map(|history| history.delay)
|
||||
}
|
||||
|
||||
/// A group row shows the delay of the node the group currently selects. The core never writes a
|
||||
/// history for the group itself, so `now` is followed until the chain reaches a node.
|
||||
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
|
||||
let mut current = group_name;
|
||||
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
|
||||
let Some(group) = find_group(view, current) else { break };
|
||||
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
|
||||
break;
|
||||
};
|
||||
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
|
||||
break;
|
||||
};
|
||||
match member {
|
||||
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
|
||||
ProxyMemberRef::Group { name } => current = name.as_str(),
|
||||
ProxyMemberRef::Unresolved { .. } => break,
|
||||
}
|
||||
}
|
||||
find_group(view, group_name)
|
||||
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
|
||||
.and_then(|history| history.last())
|
||||
.map(|history| history.delay)
|
||||
}
|
||||
|
||||
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
|
||||
let ProxyMemberRef::Node { record_id, .. } = member else {
|
||||
return None;
|
||||
};
|
||||
match &view.records.get(record_id)?.source {
|
||||
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
|
||||
ProxyNodeSource::Core { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
|
||||
let timeout = verge
|
||||
.default_latency_timeout
|
||||
.filter(|timeout| *timeout > 0)
|
||||
.unwrap_or(10000);
|
||||
let delay = match member {
|
||||
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
|
||||
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
|
||||
ProxyMemberRef::Unresolved { .. } => None,
|
||||
};
|
||||
let delay_text = delay.map_or_else(
|
||||
|| UNTESTED_DELAY_TEXT.to_owned(),
|
||||
|delay| {
|
||||
if delay == 0 || u32::from(delay) >= timeout as u32 {
|
||||
TIMEOUT_DELAY_TEXT.to_owned()
|
||||
} else {
|
||||
format!("{delay}ms")
|
||||
}
|
||||
},
|
||||
);
|
||||
format!("{} | {delay_text}", member_name(member))
|
||||
}
|
||||
|
||||
fn create_subcreate_proxy_menu_item(
|
||||
app_handle: &AppHandle,
|
||||
proxy_mode: &str,
|
||||
proxy_group_order_map: Option<HashMap<String, usize>>,
|
||||
proxy_nodes_data: Option<Proxies>,
|
||||
view: Option<ProxyViewV1>,
|
||||
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
|
||||
texts: &MenuTexts,
|
||||
verge: &IVerge,
|
||||
profiles: &crate::config::IProfiles,
|
||||
) -> Vec<Submenu<Wry>> {
|
||||
let proxy_submenus: Vec<Submenu<Wry>> = {
|
||||
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
|
||||
|
||||
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
|
||||
if let Some(proxy_nodes_data) = proxy_nodes_data {
|
||||
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
|
||||
let should_show = match proxy_mode {
|
||||
"global" => group_name == "GLOBAL",
|
||||
_ => group_name != "GLOBAL",
|
||||
} && !group_data.hidden.unwrap_or_default();
|
||||
|
||||
if !should_show {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(all_proxies) = group_data.all.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let now_proxy = group_data.now.as_deref().unwrap_or_default();
|
||||
|
||||
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
|
||||
.iter()
|
||||
.filter_map(|proxy_str| {
|
||||
let is_selected = *proxy_str == now_proxy;
|
||||
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
|
||||
|
||||
let delay_text = proxy_nodes_data
|
||||
.proxies
|
||||
.get(proxy_str)
|
||||
.and_then(|h| h.history.last())
|
||||
.map(|h| match h.delay {
|
||||
0 => "-ms".into(),
|
||||
delay if delay >= 10000 => "-ms".into(),
|
||||
_ => format!("{}ms", h.delay),
|
||||
})
|
||||
.unwrap_or_else(|| "-ms".into());
|
||||
|
||||
let display_text = format!("{} | {}", proxy_str, delay_text);
|
||||
|
||||
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if group_items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let group_display_name = group_name.to_string();
|
||||
|
||||
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
|
||||
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
|
||||
|
||||
if let Ok(submenu) = Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
format!("proxy_group_{}", group_name),
|
||||
group_display_name,
|
||||
true,
|
||||
&group_items_refs,
|
||||
) {
|
||||
let insertion_index = submenus.len();
|
||||
submenus.push((group_name.into(), insertion_index, submenu));
|
||||
} else {
|
||||
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
|
||||
}
|
||||
let Some(view) = view else { return Vec::new() };
|
||||
view.groups
|
||||
.iter()
|
||||
.chain(view.global.iter())
|
||||
.filter_map(|group| {
|
||||
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(order_map) = proxy_group_order_map.as_ref() {
|
||||
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
|
||||
match (order_map.get(name_a), order_map.get(name_b)) {
|
||||
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => original_index_a.cmp(original_index_b),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
|
||||
};
|
||||
proxy_submenus
|
||||
let url = latency::test_url(group, verge, profiles);
|
||||
let proxies: Vec<ProxyLatencyItem> = group
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|member| {
|
||||
let name = member_name(member);
|
||||
let text = proxy_item_text(&view, member, &url, verge);
|
||||
let checked = group.now.as_deref() == Some(name);
|
||||
CheckMenuItem::with_id(
|
||||
app_handle,
|
||||
format!("proxy_{}_{}", group.name, name),
|
||||
&text,
|
||||
true,
|
||||
checked,
|
||||
None::<&str>,
|
||||
)
|
||||
.map(|item| ProxyLatencyItem {
|
||||
item,
|
||||
name: name.to_owned(),
|
||||
provider_name: member_provider(&view, member).map(str::to_owned),
|
||||
text,
|
||||
})
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
|
||||
if group_items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let running = latency::is_running(&group.name);
|
||||
let test_item = MenuItem::with_id(
|
||||
app_handle,
|
||||
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
|
||||
if running {
|
||||
clash_verge_i18n::t!("tray.testingLatency")
|
||||
} else {
|
||||
texts.test_latency.clone()
|
||||
},
|
||||
!running,
|
||||
None::<&str>,
|
||||
)
|
||||
.ok()?;
|
||||
latency_items.insert(
|
||||
group.name.clone(),
|
||||
LatencyMenuItem {
|
||||
item: test_item.clone(),
|
||||
running,
|
||||
proxies,
|
||||
provider_state: view.provider_state,
|
||||
},
|
||||
);
|
||||
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
|
||||
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&test_item, &separator];
|
||||
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
|
||||
Submenu::with_id_and_items(
|
||||
app_handle,
|
||||
format!("proxy_group_{}", group.name),
|
||||
&group.name,
|
||||
true,
|
||||
&items,
|
||||
)
|
||||
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn create_proxy_menu_item(
|
||||
@@ -593,54 +798,38 @@ async fn create_tray_menu(
|
||||
tun_mode_available: bool,
|
||||
profiles_preview: Vec<IProfilePreview<'_>>,
|
||||
options: TrayMenuOptions,
|
||||
) -> Result<tauri::menu::Menu<Wry>> {
|
||||
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
|
||||
let current_proxy_mode = mode.unwrap_or("");
|
||||
|
||||
let mut verge_settings = Config::verge().await.latest_arc();
|
||||
let fetch_proxy_groups =
|
||||
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
|
||||
|
||||
// TODO: should update tray menu again when it was timeout error
|
||||
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
|
||||
let proxy_nodes_data =
|
||||
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
|
||||
.await
|
||||
.map_or(None, |res| res.ok());
|
||||
|
||||
let runtime = Config::runtime().await.latest_arc();
|
||||
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
|
||||
config
|
||||
.get("proxy-groups")
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.map(|groups| {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|group| group.get("name"))
|
||||
.filter_map(|name| name.as_str())
|
||||
.enumerate()
|
||||
.map(|(index, name)| (name.into(), index))
|
||||
.collect::<HashMap<String, usize>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
(proxy_nodes_data, runtime_proxy_groups_order)
|
||||
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
|
||||
let proxy_view = if fetch_proxy_groups {
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(1000),
|
||||
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
} else {
|
||||
(None, None)
|
||||
None
|
||||
};
|
||||
|
||||
if fetch_proxy_groups {
|
||||
verge_settings = Config::verge().await.latest_arc();
|
||||
}
|
||||
|
||||
let profiles_config = Config::profiles().await.latest_arc();
|
||||
|
||||
let tray_proxy_groups_display_mode = verge_settings
|
||||
.tray_proxy_groups_display_mode
|
||||
.as_deref()
|
||||
.unwrap_or("default");
|
||||
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
|
||||
|
||||
let proxy_group_order_map = runtime_proxy_groups_order;
|
||||
|
||||
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
|
||||
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
@@ -720,9 +909,17 @@ async fn create_tray_menu(
|
||||
&profile_menu_items_refs,
|
||||
)?;
|
||||
|
||||
let mut latency_items = HashMap::new();
|
||||
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
|
||||
let proxy_sub_menus =
|
||||
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
|
||||
let proxy_sub_menus = create_subcreate_proxy_menu_item(
|
||||
app_handle,
|
||||
current_proxy_mode,
|
||||
proxy_view,
|
||||
&mut latency_items,
|
||||
&texts,
|
||||
&verge_settings,
|
||||
&profiles_config,
|
||||
);
|
||||
|
||||
match tray_proxy_groups_display_mode {
|
||||
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
|
||||
@@ -866,10 +1063,25 @@ async fn create_tray_menu(
|
||||
]);
|
||||
|
||||
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
|
||||
Ok(menu)
|
||||
Ok((menu, latency_items))
|
||||
}
|
||||
|
||||
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
|
||||
let refresh_proxies = match tray_event {
|
||||
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left | MouseButton::Right,
|
||||
button_state: MouseButtonState::Down,
|
||||
..
|
||||
} => true,
|
||||
_ => false,
|
||||
};
|
||||
if refresh_proxies {
|
||||
AsyncHandler::spawn(|| async {
|
||||
Tray::global().refresh_proxy_items().await;
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(
|
||||
tray_event,
|
||||
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
|
||||
@@ -926,9 +1138,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
AsyncHandler::spawn(|| async move {
|
||||
match event.id.as_ref() {
|
||||
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
|
||||
if let Some(stripped) = mode.strip_prefix("tray_")
|
||||
&& let Some(final_mode) = stripped.strip_suffix("_mode")
|
||||
{
|
||||
if let Some(final_mode) = mode.strip_circumfix("tray_", "_mode") {
|
||||
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
|
||||
let _ = feat::change_clash_mode(final_mode.into()).await;
|
||||
}
|
||||
@@ -985,6 +1195,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
|
||||
};
|
||||
feat::toggle_proxy_profile(profile_index.into()).await;
|
||||
}
|
||||
id if id.starts_with(MenuIds::TEST_LATENCY) => {
|
||||
let group_name = id
|
||||
.strip_prefix(MenuIds::TEST_LATENCY)
|
||||
.and_then(|rest| rest.strip_prefix('_'));
|
||||
if let Some(group_name) = group_name {
|
||||
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
|
||||
latency::test_proxy_group_delay(group_name).await;
|
||||
}
|
||||
}
|
||||
id if id.starts_with("proxy_") => {
|
||||
let rest = match id.strip_prefix("proxy_") {
|
||||
Some(r) => r,
|
||||
|
||||
+310
-116
@@ -2,19 +2,20 @@ use crate::{config::Config, singleton, utils::dirs};
|
||||
use anyhow::{Context as _, Result};
|
||||
use chrono::Utc;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::RwLock;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt as _};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub struct SilentUpdater {
|
||||
update_ready: AtomicBool,
|
||||
pending_bytes: RwLock<Option<Vec<u8>>>,
|
||||
pending_update: RwLock<Option<Update>>,
|
||||
pending_version: RwLock<Option<String>>,
|
||||
manual_cancel: Mutex<Option<CancellationToken>>,
|
||||
background_cancel: Mutex<Option<CancellationToken>>,
|
||||
}
|
||||
|
||||
singleton!(SilentUpdater, SILENT_UPDATER);
|
||||
@@ -23,9 +24,8 @@ impl SilentUpdater {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
update_ready: AtomicBool::new(false),
|
||||
pending_bytes: RwLock::new(None),
|
||||
pending_update: RwLock::new(None),
|
||||
pending_version: RwLock::new(None),
|
||||
manual_cancel: Mutex::new(None),
|
||||
background_cancel: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,72 @@ impl SilentUpdater {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the cached installer for `update`, deleting a cache that does not match it.
|
||||
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
|
||||
let meta = Self::read_cache_meta().ok()?;
|
||||
let bytes = if meta.version == update.version {
|
||||
Self::read_cache_bytes().and_then(|bytes| {
|
||||
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
|
||||
Ok(bytes)
|
||||
})
|
||||
} else {
|
||||
Err(anyhow::anyhow!("server offers v{}", update.version))
|
||||
};
|
||||
match bytes {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Update cache v{} is unusable: {e:#}, cleaning up",
|
||||
meta.version
|
||||
);
|
||||
Self::delete_cache();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
|
||||
/// with the same rules `Update::download` applies.
|
||||
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let decode = |value: &str| -> Result<String> {
|
||||
Ok(String::from_utf8(
|
||||
base64::engine::general_purpose::STANDARD.decode(value)?,
|
||||
)?)
|
||||
};
|
||||
let config: tauri_plugin_updater::Config = serde_json::from_value(
|
||||
app_handle
|
||||
.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.cloned()
|
||||
.context("updater is not configured")?,
|
||||
)?;
|
||||
|
||||
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
|
||||
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
|
||||
public_key
|
||||
.verify(bytes, &signature, true)
|
||||
.context("signature mismatch")?;
|
||||
|
||||
match signature
|
||||
.trusted_comment()
|
||||
.split('\t')
|
||||
.find_map(|field| field.strip_prefix("version:"))
|
||||
{
|
||||
Some(signed) => anyhow::ensure!(
|
||||
signed.trim_start_matches('v') == version,
|
||||
"signed for v{signed}, not v{version}"
|
||||
),
|
||||
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||
@@ -147,9 +213,128 @@ fn nsis_language_id(app_language: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same wire shape as the updater plugin's JS `DownloadEvent`.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum DownloadEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Finished,
|
||||
}
|
||||
|
||||
const MIRRORS: [&str; 2] = ["https://update.hwdns.net/", "https://gh-proxy.org/"];
|
||||
/// A source delivering less than this per window is dropped for the next one.
|
||||
const MIN_PROGRESS: u64 = 1024 * 1024;
|
||||
const PROGRESS_WINDOW: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The manifest's URL, then the other mirror, then GitHub itself.
|
||||
fn download_sources(url: &tauri::Url) -> Vec<tauri::Url> {
|
||||
let origin = MIRRORS
|
||||
.iter()
|
||||
.find_map(|mirror| url.as_str().strip_prefix(mirror))
|
||||
.unwrap_or(url.as_str());
|
||||
let mut sources = vec![url.clone()];
|
||||
if origin.starts_with("https://github.com/") {
|
||||
let candidates = MIRRORS.iter().map(|mirror| format!("{mirror}{origin}"));
|
||||
for candidate in candidates.chain([origin.to_owned()]) {
|
||||
if let Ok(candidate) = tauri::Url::parse(&candidate)
|
||||
&& !sources.contains(&candidate)
|
||||
{
|
||||
sources.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
/// Tries every source with a throughput floor, then again stopping only on a stall, so a link
|
||||
/// that is slow everywhere still finishes.
|
||||
async fn download(update: &Update, on_event: impl Fn(DownloadEvent) + Sync) -> Result<Vec<u8>> {
|
||||
let sources = download_sources(&update.download_url);
|
||||
let mut last_error = None;
|
||||
for floor in [MIN_PROGRESS, 1] {
|
||||
for url in &sources {
|
||||
logging!(info, Type::System, "Downloading update v{} from {url}", update.version);
|
||||
match download_from(update, url, floor, &on_event).await {
|
||||
Ok(bytes) => {
|
||||
on_event(DownloadEvent::Finished);
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(warn, Type::System, "Update download from {url} failed: {e:#}");
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no update download source")))
|
||||
}
|
||||
|
||||
async fn download_from(
|
||||
update: &Update,
|
||||
url: &tauri::Url,
|
||||
floor: u64,
|
||||
on_event: &(impl Fn(DownloadEvent) + Sync),
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut source = update.clone();
|
||||
source.download_url = url.clone();
|
||||
let received = AtomicU64::new(0);
|
||||
let mut started = false;
|
||||
let fetch = source.download(
|
||||
|chunk_length, content_length| {
|
||||
if !std::mem::replace(&mut started, true) {
|
||||
on_event(DownloadEvent::Started { content_length });
|
||||
}
|
||||
received.fetch_add(chunk_length as u64, Ordering::Relaxed);
|
||||
on_event(DownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {},
|
||||
);
|
||||
tokio::pin!(fetch);
|
||||
let mut seen = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut fetch => return Ok(result?),
|
||||
() = tokio::time::sleep(PROGRESS_WINDOW) => {
|
||||
let total = received.load(Ordering::Relaxed);
|
||||
anyhow::ensure!(total - seen >= floor, "only {} bytes in {PROGRESS_WINDOW:?}", total - seen);
|
||||
seen = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SilentUpdater {
|
||||
/// Checks the server; Windows receives `/LANG` to suppress the NSIS language dialog,
|
||||
/// see `packages/windows/installer.nsi`.
|
||||
async fn check(app_handle: &tauri::AppHandle) -> Result<Option<Update>> {
|
||||
let updater_builder = app_handle.updater_builder();
|
||||
#[cfg(target_os = "windows")]
|
||||
let updater_builder = {
|
||||
let verge_lang = Config::verge().await.latest_arc().language.clone();
|
||||
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
|
||||
updater_builder.installer_arg(format!("/LANG={lang_id}"))
|
||||
};
|
||||
Ok(updater_builder.build()?.check().await?)
|
||||
}
|
||||
|
||||
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
|
||||
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
|
||||
}
|
||||
|
||||
/// Installs a newer cached update before normal startup, if the user confirms.
|
||||
pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool {
|
||||
if !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
|
||||
Self::delete_cache();
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
let meta = match Self::read_cache_meta() {
|
||||
@@ -185,71 +370,30 @@ impl SilentUpdater {
|
||||
return false;
|
||||
}
|
||||
|
||||
let bytes = match Self::read_cache_bytes() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let update = match Self::check(app_handle).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
logging!(
|
||||
warn,
|
||||
info,
|
||||
Type::System,
|
||||
"Failed to read cached update bytes: {e:#}, cleaning up"
|
||||
"No update available from server, cache may be stale, cleaning up"
|
||||
);
|
||||
Self::delete_cache();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the
|
||||
// NSIS language dialog; see `packages/windows/installer.nsi`.
|
||||
let updater_builder = app_handle.updater_builder();
|
||||
#[cfg(target_os = "windows")]
|
||||
let updater_builder = {
|
||||
let verge_lang = Config::verge().await.latest_arc().language.clone();
|
||||
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
|
||||
updater_builder.installer_arg(format!("/LANG={lang_id}"))
|
||||
};
|
||||
let update = match updater_builder.build() {
|
||||
Ok(updater) => match updater.check().await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"No update available from server, cache may be stale, cleaning up"
|
||||
);
|
||||
Self::delete_cache();
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Failed to check for update at startup: {e}, will retry next launch"
|
||||
);
|
||||
return false; // Keep cache for next attempt
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Failed to create updater: {e}, will retry next launch"
|
||||
"Failed to check for update at startup: {e:#}, will retry next launch"
|
||||
);
|
||||
return false;
|
||||
return false; // Keep cache for next attempt
|
||||
}
|
||||
};
|
||||
|
||||
// A changed server version invalidates the cached bytes.
|
||||
if update.version != *cached_version {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Server version ({}) != cached version ({}), cache is stale, cleaning up",
|
||||
update.version,
|
||||
cached_version
|
||||
);
|
||||
Self::delete_cache();
|
||||
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let version = update.version.clone();
|
||||
logging!(info, Type::System, "Installing cached update v{version} at startup...");
|
||||
@@ -257,49 +401,87 @@ impl SilentUpdater {
|
||||
Self::show_update_splash(app_handle, &version);
|
||||
|
||||
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
|
||||
let install_result = tokio::task::spawn_blocking({
|
||||
let bytes = bytes.clone();
|
||||
let update = update.clone();
|
||||
move || update.install(&bytes)
|
||||
});
|
||||
|
||||
let success = match tokio::time::timeout(std::time::Duration::from_secs(30), install_result).await {
|
||||
Ok(Ok(Ok(()))) => {
|
||||
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
|
||||
match install
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
|
||||
{
|
||||
Ok(()) => {
|
||||
logging!(info, Type::System, "Update v{version} install triggered at startup");
|
||||
Self::delete_cache();
|
||||
true
|
||||
}
|
||||
Ok(Ok(Err(e))) => {
|
||||
Err(e) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Startup install failed: {e}, will retry next launch"
|
||||
);
|
||||
false
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Startup install task panicked: {e}, will retry next launch"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::System,
|
||||
"Startup install timed out (30s), will retry next launch"
|
||||
"Startup install failed: {e:#}, will retry next launch"
|
||||
);
|
||||
Self::close_update_splash(app_handle);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
|
||||
/// `false` when cancelled before the installer starts.
|
||||
pub async fn install_update(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
version: &str,
|
||||
on_event: impl Fn(DownloadEvent) + Sync,
|
||||
) -> Result<bool> {
|
||||
let cancel = CancellationToken::new();
|
||||
*self.manual_cancel.lock() = Some(cancel.clone());
|
||||
let prepared = tokio::select! {
|
||||
prepared = Self::prepare_install(app_handle, version, on_event) => Some(prepared),
|
||||
() = cancel.cancelled() => None,
|
||||
};
|
||||
// `cancel_download` cancels under the lock, so once the token is taken back its state is
|
||||
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
|
||||
self.manual_cancel.lock().take();
|
||||
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
|
||||
logging!(info, Type::System, "Update v{version} cancelled");
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if !success {
|
||||
Self::close_update_splash(app_handle);
|
||||
}
|
||||
logging!(info, Type::System, "Installing update v{version}...");
|
||||
Self::install(update, bytes).await?;
|
||||
Self::delete_cache();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
success
|
||||
async fn prepare_install(
|
||||
app_handle: &tauri::AppHandle,
|
||||
version: &str,
|
||||
on_event: impl Fn(DownloadEvent) + Sync,
|
||||
) -> Result<(Update, Vec<u8>)> {
|
||||
let update = Self::check(app_handle)
|
||||
.await?
|
||||
.filter(|update| update.version == version)
|
||||
.with_context(|| format!("v{version} is no longer offered"))?;
|
||||
let bytes = match Self::verified_cache(app_handle, &update) {
|
||||
Some(bytes) => bytes,
|
||||
None => download(&update, on_event).await?,
|
||||
};
|
||||
Ok((update, bytes))
|
||||
}
|
||||
|
||||
pub fn cancel_download(&self) {
|
||||
let mut slot = self.manual_cancel.lock();
|
||||
if let Some(cancel) = slot.take() {
|
||||
cancel.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the downloaded update and aborts a background download in flight.
|
||||
pub fn discard_pending(&self) {
|
||||
let cancel = self.background_cancel.lock().take();
|
||||
if let Some(cancel) = cancel {
|
||||
cancel.cancel();
|
||||
}
|
||||
self.update_ready.store(false, Ordering::Release);
|
||||
Self::delete_cache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,12 +570,14 @@ impl SilentUpdater {
|
||||
|
||||
// The new webview may not accept evaluation immediately.
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..10 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1)));
|
||||
if window.eval(&js).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||
let _ = retry_sync(
|
||||
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(9), std::time::Duration::ZERO),
|
||||
|index| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100 * (index as u64 + 1)));
|
||||
window.eval(&js).map_err(RetryError::Retry)
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
logging!(info, Type::System, "Update splash window shown");
|
||||
@@ -410,6 +594,9 @@ impl SilentUpdater {
|
||||
|
||||
impl SilentUpdater {
|
||||
async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> {
|
||||
// Registered before reading the switch, so `discard_pending` either cancels it or ran first.
|
||||
let cancel = CancellationToken::new();
|
||||
*self.background_cancel.lock() = Some(cancel.clone());
|
||||
let auto_check = Config::verge().await.latest_arc().auto_check_update.unwrap_or(true);
|
||||
if !auto_check {
|
||||
logging!(debug, Type::System, "Silent update skipped: auto_check_update is false");
|
||||
@@ -447,36 +634,42 @@ impl SilentUpdater {
|
||||
Type::System,
|
||||
"Silent updater: breaking change detected in v{version}, notifying frontend"
|
||||
);
|
||||
super::handle::Handle::notice_message(
|
||||
"info",
|
||||
super::handle::Handle::notice(
|
||||
super::notify::NoticeStatus::Info,
|
||||
format!("New version v{version} contains breaking changes. Please update manually."),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_len, content_len| {
|
||||
logging!(
|
||||
debug,
|
||||
Type::System,
|
||||
"Silent updater download progress: chunk={chunk_len}, total={content_len:?}"
|
||||
);
|
||||
},
|
||||
|| {
|
||||
logging!(info, Type::System, "Silent updater: download complete");
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if Self::verified_cache(app_handle, &update).is_some() {
|
||||
logging!(info, Type::System, "Silent updater: v{version} already cached");
|
||||
} else {
|
||||
logging!(info, Type::System, "Silent updater: downloading v{version}...");
|
||||
let downloaded = tokio::select! {
|
||||
bytes = download(&update, |_| {}) => Some(bytes),
|
||||
() = cancel.cancelled() => None,
|
||||
};
|
||||
let Some(bytes) = downloaded else {
|
||||
logging!(info, Type::System, "Silent updater: download cancelled");
|
||||
return Ok(());
|
||||
};
|
||||
let bytes = bytes?;
|
||||
logging!(info, Type::System, "Silent updater: download complete");
|
||||
Self::write_cache(&bytes, &version)?;
|
||||
}
|
||||
|
||||
if let Err(e) = Self::write_cache(&bytes, &version) {
|
||||
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
|
||||
// Serialized with `discard_pending` in the verge patch, which holds the same lock.
|
||||
let _config_write = Config::lock_config_write().await;
|
||||
if cancel.is_cancelled() || !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
|
||||
logging!(
|
||||
info,
|
||||
Type::System,
|
||||
"Silent updater: auto check was disabled, discarding v{version}"
|
||||
);
|
||||
Self::delete_cache();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.pending_bytes.write() = Some(bytes);
|
||||
*self.pending_update.write() = Some(update);
|
||||
*self.pending_version.write() = Some(version.clone());
|
||||
self.update_ready.store(true, Ordering::Release);
|
||||
|
||||
logging!(
|
||||
@@ -488,6 +681,7 @@ impl SilentUpdater {
|
||||
}
|
||||
|
||||
pub async fn start_background_check(&self, app_handle: tauri::AppHandle) {
|
||||
// Permanent watcher: daily update cycles for the lifetime of the app.
|
||||
logging!(info, Type::System, "Silent updater: background task started");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::core::notify::NoticeStatus;
|
||||
mod chain;
|
||||
pub mod field;
|
||||
mod merge;
|
||||
@@ -436,7 +437,7 @@ fn notify_discarded_keys(keys: Vec<String>) {
|
||||
drop(notice);
|
||||
|
||||
if should_notify {
|
||||
Handle::notice_message("enhance::discarded_keys", "");
|
||||
Handle::notice(NoticeStatus::EnhanceDiscardedKeys, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,10 +507,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
||||
|
||||
fn is_loopback_bind_address(addr: &str) -> bool {
|
||||
let addr = addr.trim();
|
||||
let addr = addr
|
||||
.strip_prefix('[')
|
||||
.and_then(|addr| addr.strip_suffix(']'))
|
||||
.unwrap_or(addr);
|
||||
let addr = addr.strip_circumfix('[', ']').unwrap_or(addr);
|
||||
|
||||
addr.eq_ignore_ascii_case("localhost")
|
||||
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::process::AsyncHandler;
|
||||
|
||||
macro_rules! revise {
|
||||
($map: expr, $key: expr, $val: expr) => {
|
||||
let ret_key = Value::String($key.into());
|
||||
@@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
|
||||
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
|
||||
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
AsyncHandler::spawn(move || async move {
|
||||
crate::utils::resolve::dns::restore_public_dns().await;
|
||||
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
revise!(config, "dns", dns_val);
|
||||
} else {
|
||||
#[cfg(target_os = "macos")]
|
||||
AsyncHandler::spawn(move || async move {
|
||||
crate::utils::resolve::dns::restore_public_dns().await;
|
||||
});
|
||||
}
|
||||
|
||||
revise!(tun_val, "enable", enable);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::core::notify::{Refresh, announce};
|
||||
use crate::{
|
||||
config::{Config, MixedPort},
|
||||
core::{CoreManager, handle, tray},
|
||||
@@ -7,13 +9,12 @@ use crate::{
|
||||
};
|
||||
use bytes::BytesMut;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use smartstring::alias::String;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
|
||||
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
|
||||
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
@@ -26,11 +27,10 @@ static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
|
||||
pub async fn restart_clash_core() {
|
||||
match CoreManager::global().restart_core().await {
|
||||
Ok(_) => {
|
||||
handle::Handle::refresh_clash();
|
||||
handle::Handle::notice_message("set_config::ok", "ok");
|
||||
announce(Refresh::Clash);
|
||||
}
|
||||
Err(err) => {
|
||||
handle::Handle::notice_message("set_config::error", format!("{err:#}"));
|
||||
handle::Handle::notice(NoticeStatus::SetConfigError, format!("{err:#}"));
|
||||
logging!(error, Type::Core, "restart core failed: {err:#}");
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,21 @@ pub async fn restart_app() {
|
||||
|
||||
if !cleanup_result.core_stopped {
|
||||
handle::Handle::global().clear_is_exiting();
|
||||
handle::Handle::notice_message(
|
||||
"app_restart::core_stop_failed",
|
||||
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
|
||||
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
if matches!(
|
||||
*manager.get_running_mode(),
|
||||
crate::core::manager::RunningMode::NotRunning
|
||||
) {
|
||||
crate::utils::resolve::dns::sync_public_dns().await;
|
||||
}
|
||||
}
|
||||
handle::Handle::notice(
|
||||
NoticeStatus::AppRestartCoreStopFailed,
|
||||
cleanup_result.stop_error.unwrap_or_default(),
|
||||
);
|
||||
return;
|
||||
@@ -95,7 +108,7 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
|
||||
|
||||
let clash_data = clash.data_arc();
|
||||
if clash_data.save_config().await.is_ok() {
|
||||
handle::Handle::refresh_clash();
|
||||
announce(Refresh::Clash);
|
||||
tray::Tray::global().update_menu_and_icon().await;
|
||||
}
|
||||
|
||||
|
||||
+19
-295
@@ -1,279 +1,17 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{CoreManager, autostart, handle, hotkey, logger, proxy_control, tray},
|
||||
module::{auto_backup::AutoBackupManager, lightweight},
|
||||
};
|
||||
use crate::config::{Config, IVerge};
|
||||
use anyhow::Result;
|
||||
use bitflags::bitflags;
|
||||
use clash_verge_draft::{DraftTransaction, SharedDraft};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use clash_verge_draft::SharedDraft;
|
||||
use serde_yaml_ng::Mapping;
|
||||
use tokio::sync::MutexGuard;
|
||||
|
||||
/// Patch Clash configuration
|
||||
pub async fn patch_clash(patch: &Mapping) -> Result<()> {
|
||||
Config::clash().await.edit_draft(|d| d.patch_config(patch));
|
||||
|
||||
let res = {
|
||||
// 激活订阅
|
||||
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().restart_core().await?;
|
||||
} else if patch.get("allow-lan").is_some() {
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
} else {
|
||||
if patch.get("mode").is_some() {
|
||||
tray::Tray::global().update_menu_and_icon().await;
|
||||
}
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
}
|
||||
handle::Handle::refresh_clash();
|
||||
<Result<()>>::Ok(())
|
||||
};
|
||||
match res {
|
||||
Ok(()) => {
|
||||
Config::clash().await.apply();
|
||||
// 分离数据获取和异步调用
|
||||
let clash_data = Config::clash().await.data_arc();
|
||||
clash_data.save_config().await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
Config::clash().await.discard();
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define update flags as bitflags for better performance
|
||||
bitflags! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
struct UpdateFlags: u16 {
|
||||
const RESTART_CORE = 1 << 0;
|
||||
const CLASH_CONFIG = 1 << 1;
|
||||
const VERGE_CONFIG = 1 << 2;
|
||||
const LAUNCH = 1 << 3;
|
||||
const SYS_PROXY = 1 << 4;
|
||||
const SYSTRAY_ICON = 1 << 5;
|
||||
const HOTKEY = 1 << 6;
|
||||
const SYSTRAY_MENU = 1 << 7;
|
||||
const SYSTRAY_TOOLTIP = 1 << 8;
|
||||
const SYSTRAY_CLICK_BEHAVIOR = 1 << 9;
|
||||
const LIGHT_WEIGHT = 1 << 10;
|
||||
const LANGUAGE = 1 << 11;
|
||||
const LOG_LEVEL = 1 << 12;
|
||||
const LOG_FILE = 1 << 13;
|
||||
|
||||
const GROUP_SYS_TRAY = Self::SYSTRAY_MENU.bits()
|
||||
| Self::SYSTRAY_TOOLTIP.bits()
|
||||
| Self::SYSTRAY_ICON.bits();
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_update_flags(patch: &IVerge) -> UpdateFlags {
|
||||
let tun_mode = patch.enable_tun_mode;
|
||||
let auto_launch = patch.enable_auto_launch;
|
||||
let system_proxy = patch.enable_system_proxy;
|
||||
let pac = patch.proxy_auto_config;
|
||||
let pac_content = &patch.pac_file_content;
|
||||
let proxy_bypass = &patch.system_proxy_bypass;
|
||||
let language = &patch.language;
|
||||
let mixed_port = patch.verge_mixed_port;
|
||||
#[cfg(target_os = "macos")]
|
||||
let tray_icon = &patch.tray_icon;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let tray_icon: Option<String> = None;
|
||||
let common_tray_icon = patch.common_tray_icon;
|
||||
let sysproxy_tray_icon = patch.sysproxy_tray_icon;
|
||||
let tun_tray_icon = patch.tun_tray_icon;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let redir_enabled = patch.verge_redir_enabled;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let redir_port = patch.verge_redir_port;
|
||||
#[cfg(target_os = "linux")]
|
||||
let tproxy_enabled = patch.verge_tproxy_enabled;
|
||||
#[cfg(target_os = "linux")]
|
||||
let tproxy_port = patch.verge_tproxy_port;
|
||||
let socks_enabled = patch.verge_socks_enabled;
|
||||
let socks_port = patch.verge_socks_port;
|
||||
let http_enabled = patch.verge_http_enabled;
|
||||
let http_port = patch.verge_port;
|
||||
#[cfg(target_os = "macos")]
|
||||
let enable_tray_speed = patch.enable_tray_speed;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let enable_tray_speed: Option<bool> = None;
|
||||
// let enable_tray_icon = patch.enable_tray_icon;
|
||||
let enable_global_hotkey = patch.enable_global_hotkey;
|
||||
let tray_event = &patch.tray_event;
|
||||
let home_cards = patch.home_cards.as_ref();
|
||||
let enable_auto_light_weight = patch.enable_auto_light_weight_mode;
|
||||
let enable_external_controller = patch.enable_external_controller;
|
||||
let tray_proxy_groups_display_mode = &patch.tray_proxy_groups_display_mode;
|
||||
let tray_inline_outbound_modes = patch.tray_inline_outbound_modes;
|
||||
let enable_proxy_guard = patch.enable_proxy_guard;
|
||||
let proxy_guard_duration = patch.proxy_guard_duration;
|
||||
let log_level = &patch.app_log_level;
|
||||
let log_max_size = patch.app_log_max_size;
|
||||
let log_max_count = patch.app_log_max_count;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let restart_core_needed = socks_enabled.is_some()
|
||||
|| http_enabled.is_some()
|
||||
|| socks_port.is_some()
|
||||
|| http_port.is_some()
|
||||
|| mixed_port.is_some()
|
||||
|| enable_external_controller.is_some();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let mut restart_core_needed = socks_enabled.is_some()
|
||||
|| http_enabled.is_some()
|
||||
|| socks_port.is_some()
|
||||
|| http_port.is_some()
|
||||
|| mixed_port.is_some()
|
||||
|| enable_external_controller.is_some();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
restart_core_needed |= redir_enabled.is_some() || redir_port.is_some();
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
restart_core_needed |= tproxy_enabled.is_some() || tproxy_port.is_some();
|
||||
restart_core_needed |= tun_mode == Some(true);
|
||||
}
|
||||
|
||||
let mut update_flags = UpdateFlags::empty();
|
||||
if restart_core_needed {
|
||||
update_flags.insert(UpdateFlags::RESTART_CORE);
|
||||
}
|
||||
if tun_mode.is_some() {
|
||||
update_flags.insert(UpdateFlags::CLASH_CONFIG | UpdateFlags::GROUP_SYS_TRAY);
|
||||
}
|
||||
if enable_global_hotkey.is_some() || home_cards.is_some() {
|
||||
update_flags.insert(UpdateFlags::VERGE_CONFIG);
|
||||
}
|
||||
if auto_launch.is_some() {
|
||||
update_flags.insert(UpdateFlags::LAUNCH);
|
||||
}
|
||||
if system_proxy.is_some() {
|
||||
update_flags.insert(UpdateFlags::SYS_PROXY | UpdateFlags::GROUP_SYS_TRAY);
|
||||
}
|
||||
if proxy_bypass.is_some()
|
||||
|| pac_content.is_some()
|
||||
|| pac.is_some()
|
||||
|| enable_proxy_guard.is_some()
|
||||
|| proxy_guard_duration.is_some()
|
||||
{
|
||||
update_flags.insert(UpdateFlags::SYS_PROXY);
|
||||
}
|
||||
if language.is_some() {
|
||||
update_flags.insert(UpdateFlags::LANGUAGE | UpdateFlags::SYSTRAY_MENU | UpdateFlags::SYSTRAY_TOOLTIP);
|
||||
}
|
||||
if common_tray_icon.is_some()
|
||||
|| sysproxy_tray_icon.is_some()
|
||||
|| tun_tray_icon.is_some()
|
||||
|| tray_icon.is_some()
|
||||
|| enable_tray_speed.is_some()
|
||||
{
|
||||
update_flags.insert(UpdateFlags::SYSTRAY_ICON);
|
||||
}
|
||||
if patch.hotkeys.is_some() {
|
||||
update_flags.insert(UpdateFlags::HOTKEY | UpdateFlags::SYSTRAY_MENU);
|
||||
}
|
||||
if tray_event.is_some() {
|
||||
update_flags.insert(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR);
|
||||
}
|
||||
if enable_auto_light_weight.is_some() {
|
||||
update_flags.insert(UpdateFlags::LIGHT_WEIGHT);
|
||||
}
|
||||
if tray_proxy_groups_display_mode.is_some() {
|
||||
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
|
||||
}
|
||||
if log_level.is_some() {
|
||||
update_flags.insert(UpdateFlags::LOG_LEVEL);
|
||||
}
|
||||
if log_max_size.is_some() || log_max_count.is_some() {
|
||||
update_flags.insert(UpdateFlags::LOG_FILE);
|
||||
}
|
||||
if tray_inline_outbound_modes.is_some() {
|
||||
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
|
||||
}
|
||||
|
||||
update_flags
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> Result<()> {
|
||||
// Process updates based on flags
|
||||
if update_flags.contains(UpdateFlags::RESTART_CORE) {
|
||||
Config::generate().await?;
|
||||
CoreManager::global().restart_core().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::CLASH_CONFIG) {
|
||||
CoreManager::global().update_config_checked().await?;
|
||||
handle::Handle::refresh_clash();
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LAUNCH) {
|
||||
autostart::update_launch().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LANGUAGE)
|
||||
&& let Some(language) = &patch.language
|
||||
{
|
||||
clash_verge_i18n::set_locale(language.as_str());
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYS_PROXY) {
|
||||
let manager = CoreManager::global();
|
||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||
// Turning it off only writes OS state, so it must stay available while the Core is down.
|
||||
if Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.enable_system_proxy
|
||||
.unwrap_or_default()
|
||||
{
|
||||
manager.apply_proxy_after_start().await?;
|
||||
} else {
|
||||
proxy_control::apply().await?;
|
||||
proxy_control::refresh_guard().await?;
|
||||
}
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::HOTKEY)
|
||||
&& let Some(hotkeys) = &patch.hotkeys
|
||||
{
|
||||
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYSTRAY_MENU) {
|
||||
tray::Tray::global().update_menu().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYSTRAY_ICON) {
|
||||
tray::Tray::global()
|
||||
.update_icon(&Config::verge().await.latest_arc())
|
||||
.await?;
|
||||
#[cfg(target_os = "macos")]
|
||||
if patch.enable_tray_speed.is_some() {
|
||||
tray::Tray::global().update_speed_task(patch.enable_tray_speed.unwrap_or(false));
|
||||
}
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) {
|
||||
tray::Tray::global().update_tooltip().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR) {
|
||||
tray::Tray::global().update_click_behavior().await?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LIGHT_WEIGHT) {
|
||||
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
|
||||
lightweight::enable_auto_light_weight_mode().await;
|
||||
} else {
|
||||
lightweight::disable_auto_light_weight_mode();
|
||||
}
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LOG_LEVEL) {
|
||||
logger::Logger::global().update_log_level(patch.get_log_level())?;
|
||||
}
|
||||
if update_flags.contains(UpdateFlags::LOG_FILE) {
|
||||
let log_max_size = patch.app_log_max_size.unwrap_or(128);
|
||||
let log_max_count = patch.app_log_max_count.unwrap_or(8);
|
||||
logger::update_log_config(log_max_size, log_max_count).await?;
|
||||
}
|
||||
Ok(())
|
||||
let config_write = Config::try_lock_config_write()?;
|
||||
super::executor::apply(
|
||||
&config_write,
|
||||
super::executor::Patch::Clash(patch),
|
||||
super::effects::clash_effects(patch),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apply a patch, then reconcile TUN when its setting changes.
|
||||
@@ -289,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||
|
||||
/// Apply a patch without post-update reconciliation.
|
||||
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||
let config_write = Config::lock_config_write().await;
|
||||
let config_write = Config::try_lock_config_write()?;
|
||||
apply_verge_patch_locked(&config_write, patch, not_save_file).await
|
||||
}
|
||||
|
||||
@@ -300,29 +38,15 @@ pub(super) async fn apply_verge_patch_locked(
|
||||
patch: &IVerge,
|
||||
not_save_file: bool,
|
||||
) -> Result<()> {
|
||||
let verge = Config::verge().await;
|
||||
// Hold the claim across side effects so concurrent transactions cannot share this draft.
|
||||
let transaction = DraftTransaction::begin(vec![&verge])?;
|
||||
verge.edit_draft(|d| d.patch_config(patch));
|
||||
|
||||
let update_flags = determine_update_flags(patch);
|
||||
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
|
||||
// A failed patch rolls back to what the user already had; it never invents a value for them.
|
||||
process_terminated_flags(update_flags, patch).await?;
|
||||
transaction.commit();
|
||||
announce_verge_change();
|
||||
|
||||
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
|
||||
if !not_save_file {
|
||||
// 分离数据获取和异步调用
|
||||
let verge_data = verge.data_arc();
|
||||
verge_data.save_file().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn announce_verge_change() {
|
||||
handle::Handle::refresh_verge();
|
||||
super::executor::apply(
|
||||
_config_write,
|
||||
super::executor::Patch::Verge {
|
||||
patch,
|
||||
persist: !not_save_file,
|
||||
},
|
||||
super::effects::verge_effects(patch),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
|
||||
|
||||
@@ -359,24 +359,31 @@ fn stage_core(target: &Path, package: &[u8], version: &str) -> Result<StagedCore
|
||||
}
|
||||
|
||||
fn create_staging_file(directory: &Path, core_name: &OsStr) -> Result<(PathBuf, File)> {
|
||||
for _ in 0..32 {
|
||||
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let path = directory.join(format!(
|
||||
".{}.{}.{generation}.tmp",
|
||||
core_name.to_string_lossy(),
|
||||
std::process::id()
|
||||
));
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
|
||||
retry_sync(
|
||||
RetryPolicy::fixed(
|
||||
std::num::NonZeroUsize::MIN.saturating_add(31),
|
||||
std::time::Duration::ZERO,
|
||||
),
|
||||
|_| {
|
||||
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
let path = directory.join(format!(
|
||||
".{}.{}.{generation}.tmp",
|
||||
core_name.to_string_lossy(),
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
match File::options().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => return Ok((path, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| format!("failed to create {}", path.display()));
|
||||
match File::options().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => Ok((path, file)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
|
||||
anyhow::anyhow!("failed to create a unique staging file in {}", directory.display()),
|
||||
)),
|
||||
Err(error) => Err(RetryError::Stop(
|
||||
anyhow::Error::from(error).context(format!("failed to create {}", path.display())),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bail!("failed to create a unique staging file in {}", directory.display())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::core::notify::{Refresh, announce};
|
||||
use anyhow::{Result, ensure};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use serde::Serialize;
|
||||
@@ -10,7 +11,7 @@ use crate::{
|
||||
profiles::PROFILE_WRITE_LOCK,
|
||||
},
|
||||
constants::files::DNS_CONFIG,
|
||||
core::{CoreManager, handle::Handle},
|
||||
core::CoreManager,
|
||||
utils::dirs,
|
||||
};
|
||||
|
||||
@@ -74,8 +75,10 @@ pub async fn set_dns_override(
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
Handle::refresh_verge();
|
||||
Handle::refresh_clash();
|
||||
verge.data_arc().save_file().await?;
|
||||
// Core and memory state remain applied even when persistence fails.
|
||||
let result = verge.data_arc().save_file().await;
|
||||
announce(Refresh::Verge);
|
||||
announce(Refresh::Clash);
|
||||
result?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
use crate::config::IVerge;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Variant order defines execution dependency order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(super) enum Effect {
|
||||
RestartCore,
|
||||
ClashConfig,
|
||||
Autostart,
|
||||
Language,
|
||||
SystemProxy,
|
||||
Hotkey,
|
||||
TrayMenu,
|
||||
TrayIcon,
|
||||
TrayTooltip,
|
||||
TrayClick,
|
||||
Lightweight,
|
||||
LogLevel,
|
||||
LogFile,
|
||||
}
|
||||
|
||||
pub(super) type Effects = BTreeSet<Effect>;
|
||||
|
||||
macro_rules! select_field {
|
||||
($effects:ident, $field:ident, NoEffect) => { let _ = $field; };
|
||||
($effects:ident, $field:ident, [$($effect:ident),*]) => {
|
||||
if $field.is_some() { $effects.extend([$(Effect::$effect),*]); }
|
||||
};
|
||||
($effects:ident, $field:ident, Tun) => {
|
||||
select_field!($effects, $field, [ClashConfig, TrayMenu, TrayTooltip, TrayIcon]);
|
||||
#[cfg(target_os = "linux")]
|
||||
if *$field == Some(true) { $effects.insert(Effect::RestartCore); }
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! verge_registry {
|
||||
($( $(#[$cfg:meta])* $field:ident => $rule:tt; )*) => {
|
||||
pub(super) fn verge_effects(patch: &IVerge) -> Effects {
|
||||
// No rest pattern: even fields omitted by serde must be classified at compile time.
|
||||
let IVerge { $( $(#[$cfg])* $field, )* } = patch;
|
||||
let mut effects = Effects::new();
|
||||
$( $(#[$cfg])* { select_field!(effects, $field, $rule); } )*
|
||||
effects
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
verge_registry! {
|
||||
app_log_level => [LogLevel];
|
||||
app_log_max_size => [LogFile];
|
||||
app_log_max_count => [LogFile];
|
||||
language => [Language, TrayMenu, TrayTooltip];
|
||||
theme_mode => NoEffect;
|
||||
tray_event => [TrayClick];
|
||||
env_type => NoEffect;
|
||||
start_page => NoEffect;
|
||||
startup_script => NoEffect;
|
||||
traffic_graph => NoEffect;
|
||||
enable_memory_usage => NoEffect;
|
||||
enable_group_icon => NoEffect;
|
||||
pause_render_traffic_stats_on_blur => NoEffect;
|
||||
common_tray_icon => [TrayIcon];
|
||||
#[cfg(target_os = "macos")]
|
||||
tray_icon => [TrayIcon];
|
||||
menu_icon => NoEffect;
|
||||
menu_order => NoEffect;
|
||||
proxy_group_tools_position => NoEffect;
|
||||
notice_position => NoEffect;
|
||||
collapse_navbar => NoEffect;
|
||||
sysproxy_tray_icon => [TrayIcon];
|
||||
tun_tray_icon => [TrayIcon];
|
||||
enable_tun_mode => Tun;
|
||||
enable_auto_launch => [Autostart];
|
||||
enable_silent_start => NoEffect;
|
||||
enable_system_proxy => [SystemProxy, TrayMenu, TrayTooltip, TrayIcon];
|
||||
enable_proxy_guard => [SystemProxy];
|
||||
enable_bypass_check => NoEffect;
|
||||
enable_dns_settings => NoEffect;
|
||||
profile_dns_settings => NoEffect;
|
||||
use_default_bypass => [SystemProxy];
|
||||
system_proxy_bypass => [SystemProxy];
|
||||
proxy_guard_duration => [SystemProxy];
|
||||
proxy_auto_config => [SystemProxy];
|
||||
pac_file_content => [SystemProxy];
|
||||
proxy_host => [SystemProxy];
|
||||
theme_setting => NoEffect;
|
||||
web_ui_list => NoEffect;
|
||||
clash_core => NoEffect;
|
||||
hotkeys => [Hotkey, TrayMenu];
|
||||
enable_global_hotkey => NoEffect;
|
||||
home_cards => NoEffect;
|
||||
auto_close_connection => NoEffect;
|
||||
auto_check_update => NoEffect;
|
||||
default_latency_test => NoEffect;
|
||||
default_latency_timeout => NoEffect;
|
||||
enable_auto_delay_detection => NoEffect;
|
||||
auto_delay_detection_interval_minutes => NoEffect;
|
||||
enable_builtin_enhanced => NoEffect;
|
||||
proxy_layout_column => NoEffect;
|
||||
test_list => NoEffect;
|
||||
auto_log_clean => NoEffect;
|
||||
enable_auto_backup_schedule => NoEffect;
|
||||
auto_backup_interval_hours => NoEffect;
|
||||
auto_backup_on_change => NoEffect;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
verge_redir_port => [RestartCore];
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
verge_redir_enabled => [RestartCore];
|
||||
#[cfg(target_os = "linux")]
|
||||
verge_tproxy_port => [RestartCore];
|
||||
#[cfg(target_os = "linux")]
|
||||
verge_tproxy_enabled => [RestartCore];
|
||||
verge_mixed_port => [RestartCore];
|
||||
verge_socks_port => [RestartCore];
|
||||
verge_socks_enabled => [RestartCore];
|
||||
verge_port => [RestartCore];
|
||||
verge_http_enabled => [RestartCore];
|
||||
webdav_url => NoEffect;
|
||||
webdav_username => NoEffect;
|
||||
webdav_password => NoEffect;
|
||||
#[cfg(target_os = "macos")]
|
||||
enable_tray_speed => [TrayIcon];
|
||||
tray_proxy_groups_display_mode => [TrayMenu];
|
||||
tray_inline_outbound_modes => [TrayMenu];
|
||||
enable_auto_light_weight_mode => [Lightweight];
|
||||
auto_light_weight_minutes => NoEffect;
|
||||
enable_hover_jump_navigator => NoEffect;
|
||||
hover_jump_navigator_delay => NoEffect;
|
||||
enable_external_controller => [RestartCore];
|
||||
}
|
||||
|
||||
pub(super) fn clash_effects(patch: &serde_yaml_ng::Mapping) -> Effects {
|
||||
use clash_verge_logging::{Type, logging};
|
||||
let mut effects = Effects::new();
|
||||
for key in patch.keys() {
|
||||
effects.extend(match key.as_str() {
|
||||
Some("secret" | "external-controller") => &[Effect::RestartCore][..],
|
||||
Some("allow-lan") => &[Effect::ClashConfig][..],
|
||||
Some("mode") => &[Effect::ClashConfig, Effect::TrayMenu, Effect::TrayIcon][..],
|
||||
_ => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Unregistered Clash configuration key {key:?}; reloading configuration"
|
||||
);
|
||||
&[Effect::ClashConfig][..]
|
||||
}
|
||||
});
|
||||
}
|
||||
// These branches were exclusive: restart supersedes reload, and allow-lan precedes mode.
|
||||
if effects.contains(&Effect::RestartCore) {
|
||||
effects.retain(|effect| *effect == Effect::RestartCore);
|
||||
} else if patch.contains_key("allow-lan") {
|
||||
effects.retain(|effect| *effect == Effect::ClashConfig);
|
||||
}
|
||||
if patch.is_empty() {
|
||||
effects.insert(Effect::ClashConfig);
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn verge_patch(mutate: impl FnOnce(&mut IVerge)) -> IVerge {
|
||||
let mut patch = IVerge::default();
|
||||
mutate(&mut patch);
|
||||
patch
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_verge_patches_map_to_frozen_effects() {
|
||||
assert_eq!(verge_effects(&IVerge::default()), Effects::new());
|
||||
|
||||
let cases: [(IVerge, &[Effect]); 9] = [
|
||||
(
|
||||
verge_patch(|p| p.verge_mixed_port = Some(27899)),
|
||||
&[Effect::RestartCore],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.language = Some("zh".into())),
|
||||
&[Effect::TrayMenu, Effect::TrayTooltip, Effect::Language],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.hotkeys = Some(vec!["CmdOrCtrl+K".into()])),
|
||||
&[Effect::Hotkey, Effect::TrayMenu],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.enable_system_proxy = Some(true)),
|
||||
&[
|
||||
Effect::SystemProxy,
|
||||
Effect::TrayIcon,
|
||||
Effect::TrayMenu,
|
||||
Effect::TrayTooltip,
|
||||
],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.system_proxy_bypass = Some("localhost".into())),
|
||||
&[Effect::SystemProxy],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.use_default_bypass = Some(false)),
|
||||
&[Effect::SystemProxy],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.proxy_host = Some("localhost".into())),
|
||||
&[Effect::SystemProxy],
|
||||
),
|
||||
(
|
||||
verge_patch(|p| p.tray_event = Some("show_main_window".into())),
|
||||
&[Effect::TrayClick],
|
||||
),
|
||||
(verge_patch(|p| p.theme_mode = Some("dark".into())), &[]),
|
||||
];
|
||||
for (patch, expected) in cases {
|
||||
assert_eq!(
|
||||
verge_effects(&patch),
|
||||
expected.iter().copied().collect::<Effects>(),
|
||||
"patch mapping drifted for {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let tun = verge_patch(|p| p.enable_tun_mode = Some(true));
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(
|
||||
verge_effects(&tun),
|
||||
[
|
||||
Effect::RestartCore,
|
||||
Effect::ClashConfig,
|
||||
Effect::TrayIcon,
|
||||
Effect::TrayMenu,
|
||||
Effect::TrayTooltip,
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<Effects>()
|
||||
);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert_eq!(
|
||||
verge_effects(&tun),
|
||||
[
|
||||
Effect::ClashConfig,
|
||||
Effect::TrayIcon,
|
||||
Effect::TrayMenu,
|
||||
Effect::TrayTooltip,
|
||||
]
|
||||
.into_iter()
|
||||
.collect::<Effects>()
|
||||
);
|
||||
}
|
||||
|
||||
fn clash_mapping(pairs: &[(&str, &str)]) -> serde_yaml_ng::Mapping {
|
||||
let mut mapping = serde_yaml_ng::Mapping::new();
|
||||
for (key, value) in pairs {
|
||||
mapping.insert((*key).into(), (*value).into());
|
||||
}
|
||||
mapping
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clash_keys_map_to_frozen_effects() {
|
||||
let cases: [(serde_yaml_ng::Mapping, &[Effect]); 7] = [
|
||||
(clash_mapping(&[]), &[Effect::ClashConfig]),
|
||||
(clash_mapping(&[("secret", "s")]), &[Effect::RestartCore]),
|
||||
(
|
||||
clash_mapping(&[("mode", "rule")]),
|
||||
&[Effect::ClashConfig, Effect::TrayIcon, Effect::TrayMenu],
|
||||
),
|
||||
(clash_mapping(&[("allow-lan", "true")]), &[Effect::ClashConfig]),
|
||||
(clash_mapping(&[("not-a-known-key", "1")]), &[Effect::ClashConfig]),
|
||||
// Restart supersedes reload; allow-lan precedes mode.
|
||||
(
|
||||
clash_mapping(&[("secret", "s"), ("mode", "rule")]),
|
||||
&[Effect::RestartCore],
|
||||
),
|
||||
(
|
||||
clash_mapping(&[("allow-lan", "true"), ("mode", "rule")]),
|
||||
&[Effect::ClashConfig],
|
||||
),
|
||||
];
|
||||
for (patch, expected) in cases {
|
||||
assert_eq!(
|
||||
clash_effects(&patch),
|
||||
expected.iter().copied().collect::<Effects>(),
|
||||
"clash mapping drifted for {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user