mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea509b8236 | ||
|
|
52d616302c | ||
|
|
8ddd99f226 | ||
|
|
e0e97a920e | ||
|
|
c4f9d65e07 | ||
|
|
0c7e0c8535 | ||
|
|
1638ab48d8 | ||
|
|
5d7dc3aed9 | ||
|
|
b057bd964c | ||
|
|
897a117dc5 | ||
|
|
6a752994cf | ||
|
|
32e5d5bda3 | ||
|
|
daae17a40c | ||
|
|
1f140dfd3a | ||
|
|
04d74ac48f | ||
|
|
78bf79cc09 | ||
|
|
61c33c6113 | ||
|
|
ae4f416766 | ||
|
|
fbc4b516b8 | ||
|
|
3456de1fb6 | ||
|
|
f9ef9d78cb | ||
|
|
3458c9b075 | ||
|
|
afc32f568a | ||
|
|
22e3f1ac8a | ||
|
|
758300ac34 | ||
|
|
659ee1a181 | ||
|
|
fd06fbb3b7 | ||
|
|
ed0b801573 | ||
|
|
ff1ce92fac | ||
|
|
6a85d0344e | ||
|
|
bffe91db5e | ||
|
|
93b7bb8fe8 | ||
|
|
3b958acb22 | ||
|
|
d374ab4350 | ||
|
|
b7197f6f4b | ||
|
|
7bf8e1dcf5 | ||
|
|
07825afb1e | ||
|
|
d7e0126586 | ||
|
|
5059cdcb9d | ||
|
|
af0f502f98 | ||
|
|
8982525fd2 | ||
|
|
81ce247ecc | ||
|
|
b469bee352 | ||
|
|
df79ed3efb | ||
|
|
c6a4de1c6a | ||
|
|
433cd0628f | ||
|
|
13676d3335 | ||
|
|
2a7972fa5b | ||
|
|
19334368cd | ||
|
|
73ea0fbd80 | ||
|
|
9f97d938f5 | ||
|
|
04a2a09368 | ||
|
|
94491b4ba4 | ||
|
|
78516dc1dc | ||
|
|
8f6fd3bbf9 | ||
|
|
c599f5547e | ||
|
|
f6fdb34745 | ||
|
|
65ed8d3cf2 | ||
|
|
2baa629ac4 | ||
|
|
c7ffb21294 | ||
|
|
0d0ed72467 | ||
|
|
a36dcd319f | ||
|
|
9a879dd8d8 | ||
|
|
20141e791e | ||
|
|
e385d37e5b | ||
|
|
2f097790ed | ||
|
|
619d6ae36a | ||
|
|
de24496fa7 | ||
|
|
f58e0a0af5 | ||
|
|
a8e8fe66dc | ||
|
|
75c921b466 | ||
|
|
e3bdea4386 | ||
|
|
ce963d175a | ||
|
|
d289a608d9 | ||
|
|
8a5548af10 | ||
|
|
1d9bca13c8 | ||
|
|
6f19c3dfaa |
@@ -5,7 +5,7 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:2": {
|
"ghcr.io/devcontainers/features/node:2": {
|
||||||
"version": "24.21.0",
|
"version": "24.21.0",
|
||||||
"pnpmVersion": "12.3.3"
|
"pnpmVersion": "12.8.1"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/rust:1": {
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
"version": "1.98.1",
|
"version": "1.98.1",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"version": "v9.0.0",
|
"version": "v9.0.0",
|
||||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||||
},
|
},
|
||||||
"github/gh-aw-actions/setup@v0.89.1": {
|
"github/gh-aw-actions/setup@v0.89.21": {
|
||||||
"repo": "github/gh-aw-actions/setup",
|
"repo": "github/gh-aw-actions/setup",
|
||||||
"version": "v0.89.1",
|
"version": "v0.89.21",
|
||||||
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
|
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"containers": {
|
"containers": {
|
||||||
@@ -22,6 +22,16 @@
|
|||||||
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
||||||
},
|
},
|
||||||
|
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
|
||||||
|
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
||||||
|
},
|
||||||
|
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
|
||||||
|
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
|
||||||
|
},
|
||||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||||
"image": "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",
|
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||||
@@ -32,6 +42,16 @@
|
|||||||
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
||||||
},
|
},
|
||||||
|
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
|
||||||
|
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
||||||
|
},
|
||||||
|
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
|
||||||
|
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
|
||||||
|
},
|
||||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||||
@@ -42,20 +62,30 @@
|
|||||||
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
|
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
|
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
|
||||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
|
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
|
||||||
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
|
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
||||||
|
},
|
||||||
|
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
|
||||||
|
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
|
||||||
|
},
|
||||||
|
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
|
||||||
|
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
|
||||||
|
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
|
||||||
|
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-node": {
|
"ghcr.io/github/gh-aw-node": {
|
||||||
"image": "ghcr.io/github/gh-aw-node",
|
"image": "ghcr.io/github/gh-aw-node",
|
||||||
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/github-mcp-server:v1.12.1": {
|
"ghcr.io/github/github-mcp-server:v1.12.2": {
|
||||||
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
|
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
|
||||||
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
|
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
|
||||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
|
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,19 @@ jobs:
|
|||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||||
DRAFT: ${{ github.event.pull_request.draft }}
|
DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
|
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||||
|
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||||
run: |
|
run: |
|
||||||
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
||||||
echo "Draft or no-issue-needed; linkage check skipped."
|
echo "Draft or no-issue-needed; linkage check skipped."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
|
||||||
|
# skip-roles/skip-bots trust tier.
|
||||||
|
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
||||||
|
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
||||||
echo "Issue reference found."
|
echo "Issue reference found."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ name: Auto Build
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
|
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
|
||||||
- cron: '0 4,10 * * *'
|
- cron: '0 10,22 * * *'
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
env:
|
env:
|
||||||
TAG_NAME: autobuild
|
TAG_NAME: autobuild
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
@@ -580,7 +580,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -31,6 +31,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install gh-aw extension
|
- name: Install gh-aw extension
|
||||||
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
|
||||||
with:
|
with:
|
||||||
version: v0.89.1
|
version: v0.89.15
|
||||||
|
|||||||
+171
-127
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)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
@@ -691,7 +691,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
nodejs 24.21.0
|
nodejs 24.21.0
|
||||||
pnpm 12.4.1
|
pnpm 12.8.1
|
||||||
rust 1.98.1
|
rust 1.98.1
|
||||||
|
|||||||
Generated
+460
-602
File diff suppressed because it is too large
Load Diff
+20
-7
@@ -46,6 +46,12 @@ inherits = "fast-release"
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
split-debuginfo = "unpacked"
|
split-debuginfo = "unpacked"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# macOS Golden Gate fix for tray-icon click reentrancy issue
|
||||||
|
# Remove the following patch once the fix is merged upstream(tauri)
|
||||||
|
# https://github.com/tauri-apps/tray-icon/pull/365
|
||||||
|
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||||
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
||||||
@@ -55,10 +61,10 @@ clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
|||||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
||||||
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
tracing = "0.1"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
tauri = { version = "2.11.5" }
|
tauri = { version = "2.11.6" }
|
||||||
tauri-plugin-clipboard-manager = "2.3.2"
|
tauri-plugin-clipboard-manager = "2.3.3"
|
||||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
@@ -70,9 +76,15 @@ tokio = { version = "1.53.1", features = [
|
|||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
flexi_logger = "0.31.10"
|
flexi_logger = "0.31.10"
|
||||||
log = "0.4.33"
|
log = "0.4.34"
|
||||||
regex = "1.13.1"
|
regex = "1.13.1"
|
||||||
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
reqwest = { version = "0.13.5", features = [
|
||||||
|
"json",
|
||||||
|
"cookies",
|
||||||
|
"gzip",
|
||||||
|
"rustls",
|
||||||
|
"form",
|
||||||
|
] }
|
||||||
rust_iso3166 = "0.2.0"
|
rust_iso3166 = "0.2.0"
|
||||||
|
|
||||||
smartstring = { version = "1.0.1" }
|
smartstring = { version = "1.0.1" }
|
||||||
@@ -80,7 +92,7 @@ smartstring = { version = "1.0.1" }
|
|||||||
serde = { version = "1.0.229" }
|
serde = { version = "1.0.229" }
|
||||||
serde_json = { version = "1.0.151" }
|
serde_json = { version = "1.0.151" }
|
||||||
serde_yaml_ng = { version = "0.10.0" }
|
serde_yaml_ng = { version = "0.10.0" }
|
||||||
bitflags = { version = "2.13.1" }
|
bitflags = { version = "2.13.2" }
|
||||||
socket2 = "0.6.5"
|
socket2 = "0.6.5"
|
||||||
|
|
||||||
# *** For Windows platform only ***
|
# *** For Windows platform only ***
|
||||||
@@ -93,7 +105,8 @@ windows-sys = { version = "0.61.2", features = [
|
|||||||
"Win32_Security_Authorization",
|
"Win32_Security_Authorization",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_System_JobObjects"
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_JobObjects",
|
||||||
] }
|
] }
|
||||||
# *********************************
|
# *********************************
|
||||||
|
|
||||||
|
|||||||
+11
-76
@@ -1,100 +1,35 @@
|
|||||||
## v2.5.4
|
## v2.5.7
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
||||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
||||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
|
||||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
|
||||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
|
||||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
|
||||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
|
||||||
- 修复重启内核后未恢复上次所选节点的问题
|
|
||||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
|
||||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
|
||||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
|
||||||
- 修复切换节点时应用可能卡住的问题
|
|
||||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
|
||||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
|
||||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
|
||||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
|
||||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
|
||||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 修复 Windows 系统代理设置偶发失败的问题
|
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||||
- 修复 Windows 关机被应用阻止的问题
|
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
||||||
|
|
||||||
**🍎 macOS**
|
**🍎 macOS**
|
||||||
|
|
||||||
- 修复 macOS 内核更新失败后无法启动的问题
|
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
||||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
||||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
|
||||||
|
|
||||||
**🍎/🐧 macOS/Linux**
|
**🍎/🐧 macOS/Linux**
|
||||||
|
|
||||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
||||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
|
||||||
|
|
||||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
|
||||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
|
||||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
|
||||||
- 新增手动取消节点固定功能
|
|
||||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
|
||||||
- 新增前置规则与后置规则互相移动功能
|
|
||||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
|
||||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
|
||||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
|
||||||
- 新增启动失败提示,显示原因并保存启动日志
|
|
||||||
|
|
||||||
**🍎 macOS**
|
|
||||||
|
|
||||||
- 新增 macOS Liquid Glass 风格图标
|
|
||||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
||||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
|
||||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
|
||||||
- 优化代理列表刷新、排序和页面切换速度
|
|
||||||
- 优化首页节点操作,显示节点固定状态
|
|
||||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
|
||||||
- 优化更新说明排版,支持表格和提示框
|
|
||||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
|
||||||
- 优化后台连接和托盘刷新,降低资源占用
|
|
||||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
|
||||||
- 优化日志记录,减少重复信息
|
|
||||||
- 移除便携模式,统一使用系统应用数据目录
|
|
||||||
- 优化繁体中文、俄语翻译及语言名称显示
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 优化 Windows 自动更新,跳过安装语言选择
|
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-i18n = "4.2.1"
|
rust-i18n = "4.2.2"
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: على وشك الخروج
|
title: على وشك الخروج
|
||||||
body: Clash Verge على وشك الخروج.
|
body: Clash Verge على وشك الخروج.
|
||||||
appHidden:
|
|
||||||
title: تم إخفاء التطبيق
|
|
||||||
body: Clash Verge يعمل في الخلفية.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Beenden steht bevor
|
title: Beenden steht bevor
|
||||||
body: Clash Verge wird gleich beendet.
|
body: Clash Verge wird gleich beendet.
|
||||||
appHidden:
|
|
||||||
title: Anwendung ausgeblendet
|
|
||||||
body: Clash Verge läuft im Hintergrund.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: About to Exit
|
title: About to Exit
|
||||||
body: Clash Verge is about to exit.
|
body: Clash Verge is about to exit.
|
||||||
appHidden:
|
|
||||||
title: Application Hidden
|
|
||||||
body: Clash Verge is running in the background.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: A punto de salir
|
title: A punto de salir
|
||||||
body: Clash Verge está a punto de salir.
|
body: Clash Verge está a punto de salir.
|
||||||
appHidden:
|
|
||||||
title: Aplicación oculta
|
|
||||||
body: Clash Verge se está ejecutando en segundo plano.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: در آستانه خروج
|
title: در آستانه خروج
|
||||||
body: Clash Verge در آستانه خروج است.
|
body: Clash Verge در آستانه خروج است.
|
||||||
appHidden:
|
|
||||||
title: برنامه پنهان شد
|
|
||||||
body: Clash Verge در پسزمینه در حال اجراست.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Akan Keluar
|
title: Akan Keluar
|
||||||
body: Clash Verge akan keluar.
|
body: Clash Verge akan keluar.
|
||||||
appHidden:
|
|
||||||
title: Aplikasi Disembunyikan
|
|
||||||
body: Clash Verge berjalan di latar belakang.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 終了間近
|
title: 終了間近
|
||||||
body: Clash Verge はまもなく終了します。
|
body: Clash Verge はまもなく終了します。
|
||||||
appHidden:
|
|
||||||
title: アプリが非表示
|
|
||||||
body: Clash Verge はバックグラウンドで実行中です。
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 곧 종료
|
title: 곧 종료
|
||||||
body: Clash Verge가 곧 종료됩니다.
|
body: Clash Verge가 곧 종료됩니다.
|
||||||
appHidden:
|
|
||||||
title: 앱이 숨겨짐
|
|
||||||
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Скорый выход
|
title: Скорый выход
|
||||||
body: Clash Verge скоро завершит работу.
|
body: Clash Verge скоро завершит работу.
|
||||||
appHidden:
|
|
||||||
title: Приложение скрыто
|
|
||||||
body: Clash Verge работает в фоновом режиме.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Обновление Clash Verge
|
title: Обновление Clash Verge
|
||||||
body: Новая версия (v{version}) скачана и готова к установке.
|
body: Новая версия (v{version}) скачана и готова к установке.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ notifications:
|
|||||||
body: '{mode} moduna geçildi.'
|
body: '{mode} moduna geçildi.'
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Sistem Vekil'i
|
title: Sistem Vekil'i
|
||||||
'on': System proxy has been enabled.
|
'on': Sistem vekili etkinleştirildi.
|
||||||
'off': System proxy has been disabled.
|
'off': Sistem vekili devre dışı bırakıldı.
|
||||||
systemProxyFailed:
|
systemProxyFailed:
|
||||||
title: Sistem Vekili
|
title: Sistem Vekili
|
||||||
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
||||||
@@ -18,8 +18,8 @@ notifications:
|
|||||||
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN Modu
|
title: TUN Modu
|
||||||
'on': TUN mode has been enabled.
|
'on': TUN modu etkinleştirildi.
|
||||||
'off': TUN mode has been disabled.
|
'off': TUN modu devre dışı bırakıldı.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Hafif Mod
|
title: Hafif Mod
|
||||||
body: Hafif moda geçildi.
|
body: Hafif moda geçildi.
|
||||||
@@ -29,14 +29,11 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Çıkış Yapılmak Üzere
|
title: Çıkış Yapılmak Üzere
|
||||||
body: Clash Verge kapanmak üzere.
|
body: Clash Verge kapanmak üzere.
|
||||||
appHidden:
|
|
||||||
title: Uygulama Gizlendi
|
|
||||||
body: Clash Verge arka planda çalışıyor.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Güncellemesi
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
|
||||||
installNow: Install Now
|
installNow: Şimdi Kur
|
||||||
later: Later
|
later: Daha Sonra
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
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.
|
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Чыгар алдыннан
|
title: Чыгар алдыннан
|
||||||
body: Clash Verge чыгарга җыена.
|
body: Clash Verge чыгарга җыена.
|
||||||
appHidden:
|
|
||||||
title: Кушымта яшерелде
|
|
||||||
body: Clash Verge фон режимында эшли.
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 即将退出
|
title: 即将退出
|
||||||
body: Clash Verge 即将退出。
|
body: Clash Verge 即将退出。
|
||||||
appHidden:
|
|
||||||
title: 应用已隐藏
|
|
||||||
body: Clash Verge 正在后台运行。
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge 更新
|
title: Clash Verge 更新
|
||||||
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 即將退出
|
title: 即將退出
|
||||||
body: Clash Verge 即將退出。
|
body: Clash Verge 即將退出。
|
||||||
appHidden:
|
|
||||||
title: 應用已隱藏
|
|
||||||
body: Clash Verge 正在背景執行。
|
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge 更新
|
title: Clash Verge 更新
|
||||||
body: 新版本(v{version})已下載完成,是否立即安裝?
|
body: 新版本(v{version})已下載完成,是否立即安裝?
|
||||||
|
|||||||
@@ -1,3 +1,180 @@
|
|||||||
|
## v2.5.6
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
|
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
||||||
|
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||||
|
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
||||||
|
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
|
||||||
|
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
|
||||||
|
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
|
||||||
|
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
|
||||||
|
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
|
||||||
|
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
|
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v2.5.5
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
|
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||||
|
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||||
|
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||||
|
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||||
|
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||||
|
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||||
|
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||||
|
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||||
|
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||||
|
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
|
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||||
|
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||||
|
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||||
|
- 优化预构建资源,补充 ASN 数据库文件
|
||||||
|
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v2.5.4
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
|
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||||
|
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||||
|
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||||
|
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||||
|
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||||
|
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||||
|
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||||
|
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||||
|
- 修复重启内核后未恢复上次所选节点的问题
|
||||||
|
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||||
|
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||||
|
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||||
|
- 修复切换节点时应用可能卡住的问题
|
||||||
|
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||||
|
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||||
|
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||||
|
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||||
|
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||||
|
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||||
|
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||||
|
- 修复 Windows 系统代理设置偶发失败的问题
|
||||||
|
- 修复 Windows 关机被应用阻止的问题
|
||||||
|
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||||
|
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- 修复 macOS 内核更新失败后无法启动的问题
|
||||||
|
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||||
|
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||||
|
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||||
|
|
||||||
|
**🍎/🐧 macOS/Linux**
|
||||||
|
|
||||||
|
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||||
|
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
|
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||||
|
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||||
|
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||||
|
- 新增手动取消节点固定功能
|
||||||
|
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||||
|
- 新增前置规则与后置规则互相移动功能
|
||||||
|
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||||
|
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||||
|
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||||
|
- 新增启动失败提示,显示原因并保存启动日志
|
||||||
|
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- 新增 macOS Liquid Glass 风格图标
|
||||||
|
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
|
- 优化首页节点菜单关闭时的刷新性能
|
||||||
|
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||||
|
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||||
|
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||||
|
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||||
|
- 优化代理列表刷新、排序和页面切换速度
|
||||||
|
- 优化首页节点操作,显示节点固定状态
|
||||||
|
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||||
|
- 优化更新说明排版,支持表格和提示框
|
||||||
|
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||||
|
- 优化后台连接和托盘刷新,降低资源占用
|
||||||
|
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||||
|
- 优化日志记录,减少重复信息
|
||||||
|
- 移除便携模式,统一使用系统应用数据目录
|
||||||
|
- 优化繁体中文、俄语翻译及语言名称显示
|
||||||
|
|
||||||
|
**🖥️ Windows**
|
||||||
|
|
||||||
|
- 优化 Windows 自动更新,跳过安装语言选择
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
## v2.5.2
|
## v2.5.2
|
||||||
|
|
||||||
### 🐞 修复问题
|
### 🐞 修复问题
|
||||||
|
|||||||
+3
-3
@@ -51,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
|||||||
|
|
||||||
## Promotion
|
## 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**.
|
🚀 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:**
|
#### **Core Advantages:**
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
|||||||
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
- ⚡ 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**
|
- 🎬 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)
|
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -51,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
|||||||
|
|
||||||
## Promociones
|
## 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.
|
- 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)
|
- 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
|
- 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
|
- 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
|
- 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)
|
- 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
|
- 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)
|
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -50,17 +50,17 @@
|
|||||||
|
|
||||||
## تبلیغات
|
## تبلیغات
|
||||||
|
|
||||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
||||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
||||||
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
||||||
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
||||||
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
||||||
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
||||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||||
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -51,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
|||||||
|
|
||||||
## プロモーション
|
## プロモーション
|
||||||
|
|
||||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
- 無料トライアル、割引プラン、ストリーミング解放、世界初の 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 名)
|
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
||||||
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
||||||
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
||||||
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
||||||
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
||||||
- ストリーミングおよび ChatGPT の利用にも対応
|
- ストリーミングおよび 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)
|
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -51,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
|||||||
|
|
||||||
## 프로모션
|
## 프로모션
|
||||||
|
|
||||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 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회)
|
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
||||||
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
||||||
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
||||||
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
||||||
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
||||||
- 스트리밍 및 ChatGPT 접근 지원
|
- 스트리밍 및 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)
|
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -47,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
|||||||
|
|
||||||
## Продвижение
|
## Продвижение
|
||||||
|
|
||||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
- Высокопроизводительный иностранный 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 штук)
|
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
||||||
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
||||||
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
||||||
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
||||||
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
||||||
- Разблокировка потоковые сервисы и ChatGPT
|
- Разблокировка потоковые сервисы и 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)
|
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+55
-55
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.5.4",
|
"version": "2.5.7",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -49,90 +49,90 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.4.0",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.4.0",
|
||||||
"@tanstack/react-virtual": "^3.13.25",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@tauri-apps/api": "2.11.1",
|
"@tauri-apps/api": "2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.3",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.2",
|
||||||
"@tauri-apps/plugin-http": "~2.5.9",
|
"@tauri-apps/plugin-http": "~2.6.1",
|
||||||
"@tauri-apps/plugin-opener": "2.5.4",
|
"@tauri-apps/plugin-opener": "2.5.5",
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
"@tauri-apps/plugin-updater": "2.12.0",
|
||||||
"ahooks": "^3.9.7",
|
"ahooks": "^3.10.0",
|
||||||
"cidr-block": "^2.3.0",
|
"cidr-block": "^2.3.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dayjs": "1.11.21",
|
"dayjs": "1.11.23",
|
||||||
"foxact": "^0.3.4",
|
"foxact": "^0.3.12",
|
||||||
"foxts": "^5.4.2",
|
"foxts": "^5.9.1",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.4.2",
|
||||||
"js-yaml": "^5.0.0",
|
"js-yaml": "^5.4.2",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"meta-json-schema": "^1.19.25",
|
"meta-json-schema": "^1.19.31",
|
||||||
"monaco-editor": "^0.56.0",
|
"monaco-editor": "^0.56.0",
|
||||||
"monaco-yaml": "^5.5.0",
|
"monaco-yaml": "^5.5.1",
|
||||||
"nanoid": "^6.0.0",
|
"nanoid": "^6.0.1",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.8",
|
"react": "19.3.0",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.3.0",
|
||||||
"react-error-boundary": "6.1.2",
|
"react-error-boundary": "6.1.5",
|
||||||
"react-hook-form": "^7.76.1",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "17.0.11",
|
"react-i18next": "17.0.14",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^8.0.0",
|
"react-router": "^8.4.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"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",
|
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
"validator": "^13.15.35"
|
"validator": "^13.15.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "^9.1.1",
|
"@actions/github": "^9.1.1",
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.5.14",
|
||||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "2.11.4",
|
"@tauri-apps/cli": "2.11.4",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.13.6",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.3.0",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.3.0",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"@vitejs/plugin-legacy": "^8.0.2",
|
"@vitejs/plugin-legacy": "^8.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
"@wdio/tauri-service": "1.3.0",
|
"@wdio/tauri-service": "1.4.0",
|
||||||
"adm-zip": "^0.6.0",
|
"adm-zip": "^0.6.1",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.20.0",
|
||||||
"cli-color": "^2.0.4",
|
"cli-color": "^2.0.4",
|
||||||
"commander": "^15.0.0",
|
"commander": "^15.0.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.11.0",
|
||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
"eslint-plugin-es-x": "^10.0.0",
|
"eslint-plugin-es-x": "^10.0.1",
|
||||||
"eslint-plugin-import-x": "^4.16.2",
|
"eslint-plugin-import-x": "^4.17.1",
|
||||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.7",
|
||||||
"eslint-plugin-sukka": "^10.0.0",
|
"eslint-plugin-sukka": "^10.2.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.12.0",
|
||||||
"https-proxy-agent": "^9.0.0",
|
"https-proxy-agent": "^9.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"knip": "6.32.1",
|
"knip": "6.35.1",
|
||||||
"lint-staged": "^17.0.5",
|
"lint-staged": "^17.5.1",
|
||||||
"sass": "^1.100.0",
|
"sass": "^1.104.1",
|
||||||
"tar": "^7.5.15",
|
"tar": "^7.5.22",
|
||||||
"terser": "^5.48.0",
|
"terser": "^5.51.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.4",
|
"typescript-eslint": "^8.70.0",
|
||||||
"vite": "^8.0.14",
|
"vite": "^8.3.0",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^5.0.0",
|
"vitest": "^5.0.1",
|
||||||
"webdriverio": "9.30.0",
|
"webdriverio": "9.31.9",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.3"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs}": [
|
"*.{ts,tsx,js,mjs}": [
|
||||||
@@ -144,5 +144,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@12.4.1"
|
"packageManager": "pnpm@12.8.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1301
-1335
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -56,6 +56,12 @@
|
|||||||
"matchFileNames": [".github/workflows/*.lock.yml"],
|
"matchFileNames": [".github/workflows/*.lock.yml"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
|
||||||
|
"matchFileNames": [".github/workflows/**"],
|
||||||
|
"matchPackageNames": ["/gh-aw/"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Skip TypeScript 7.0 series",
|
"description": "Skip TypeScript 7.0 series",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
@@ -78,7 +84,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
||||||
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
|
"matchPackageNames": ["pnpm"],
|
||||||
"groupName": "pnpm"
|
"groupName": "pnpm"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,6 +137,16 @@
|
|||||||
],
|
],
|
||||||
"depNameTemplate": "rust-lang/rust",
|
"depNameTemplate": "rust-lang/rust",
|
||||||
"datasourceTemplate": "github-releases"
|
"datasourceTemplate": "github-releases"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"description": "Update pnpm in devcontainer.json",
|
||||||
|
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
|
||||||
|
"matchStrings": [
|
||||||
|
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
|
||||||
|
],
|
||||||
|
"depNameTemplate": "pnpm",
|
||||||
|
"datasourceTemplate": "npm"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"postUpdateOptions": ["pnpmDedupe"],
|
"postUpdateOptions": ["pnpmDedupe"],
|
||||||
|
|||||||
+86
-142
@@ -1,36 +1,23 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { execFileSync, spawn } from 'node:child_process'
|
||||||
import { createHash } from 'node:crypto'
|
import { access } from 'node:fs/promises'
|
||||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
export const developmentServiceDirectoryEnvironment =
|
export const developmentServiceDirectoryEnvironment =
|
||||||
'CLASH_VERGE_DEV_SERVICE_DIR'
|
'CLASH_VERGE_DEV_SERVICE_DIR'
|
||||||
const developmentServiceInstallerEnvironment =
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
|
|
||||||
|
|
||||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const repositoryRoot = resolve(scriptDirectory, '..')
|
|
||||||
const serviceRepository = resolve(
|
const serviceRepository = resolve(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
'..',
|
'..',
|
||||||
'clash-verge-service-ipc',
|
'clash-verge-service-ipc',
|
||||||
)
|
)
|
||||||
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
||||||
const developmentReceipt = join(
|
export const developmentServiceWatchPaths = [
|
||||||
repositoryRoot,
|
'src',
|
||||||
'target',
|
'resources',
|
||||||
'development-service.json',
|
'Cargo.toml',
|
||||||
)
|
'Cargo.lock',
|
||||||
|
].map((name) => join(serviceRepository, name))
|
||||||
function executable(name, platform) {
|
|
||||||
return join(
|
|
||||||
serviceRepository,
|
|
||||||
'target',
|
|
||||||
'debug',
|
|
||||||
platform === 'win32' ? `${name}.exe` : name,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
@@ -39,10 +26,15 @@ function run(command, args, options = {}) {
|
|||||||
stdio: options.stdio ?? 'inherit',
|
stdio: options.stdio ?? 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
|
let stdout = ''
|
||||||
|
child.stdout?.setEncoding('utf8')
|
||||||
|
child.stdout?.on('data', (chunk) => {
|
||||||
|
stdout += chunk
|
||||||
|
})
|
||||||
child.once('error', reject)
|
child.once('error', reject)
|
||||||
child.once('exit', (code, signal) => {
|
child.once('close', (code, signal) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolvePromise()
|
resolvePromise(stdout)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
@@ -54,134 +46,86 @@ function run(command, args, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashFiles(paths) {
|
export async function prepareDevelopmentService() {
|
||||||
const hash = createHash('sha256')
|
|
||||||
for (const path of paths) {
|
|
||||||
hash.update(await readFile(path))
|
|
||||||
}
|
|
||||||
return hash.digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readInstalledFingerprint() {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
|
|
||||||
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeInstalledFingerprint(fingerprint) {
|
|
||||||
await mkdir(dirname(developmentReceipt), { recursive: true })
|
|
||||||
await writeFile(
|
|
||||||
developmentReceipt,
|
|
||||||
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function windowsElevationInvocation(installer, environment = process.env) {
|
|
||||||
const script = [
|
|
||||||
"$ErrorActionPreference = 'Stop'",
|
|
||||||
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
|
|
||||||
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
|
|
||||||
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
|
|
||||||
'exit $child.ExitCode',
|
|
||||||
].join('; ')
|
|
||||||
return {
|
|
||||||
command: 'powershell.exe',
|
|
||||||
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
||||||
env: {
|
|
||||||
...environment,
|
|
||||||
[developmentServiceInstallerEnvironment]: installer,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function elevateInstaller(installer, platform) {
|
|
||||||
if (platform === 'win32') {
|
|
||||||
const invocation = windowsElevationInvocation(installer)
|
|
||||||
return run(invocation.command, invocation.args, { env: invocation.env })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform === 'darwin') {
|
|
||||||
const script =
|
|
||||||
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
|
|
||||||
return run('osascript', ['-e', script, installer, String(process.getgid())])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
||||||
return run(installer, [], {
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await run('pkexec', [installer])
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
|
|
||||||
await run('sudo', [installer])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function prepareDevelopmentService({
|
|
||||||
platform = process.platform,
|
|
||||||
} = {}) {
|
|
||||||
await access(serviceManifest)
|
await access(serviceManifest)
|
||||||
await run('cargo', [
|
const output = await run(
|
||||||
'build',
|
'cargo',
|
||||||
'--manifest-path',
|
[
|
||||||
serviceManifest,
|
'build',
|
||||||
'--features',
|
'--manifest-path',
|
||||||
'standalone,client,development-channel',
|
serviceManifest,
|
||||||
'--bins',
|
'--target-dir',
|
||||||
])
|
process.env.CARGO_TARGET_DIR ||
|
||||||
|
join(serviceRepository, 'target', 'development'),
|
||||||
|
'--features',
|
||||||
|
'standalone,client,development-channel',
|
||||||
|
'--bins',
|
||||||
|
'--message-format=json-render-diagnostics',
|
||||||
|
],
|
||||||
|
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
||||||
|
)
|
||||||
|
|
||||||
const service = executable('clash-verge-service', platform)
|
const artifacts = new Map()
|
||||||
await access(service)
|
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
||||||
return dirname(service)
|
const artifact = JSON.parse(line)
|
||||||
|
if (
|
||||||
|
artifact.reason === 'compiler-artifact' &&
|
||||||
|
resolve(artifact.manifest_path) === serviceManifest &&
|
||||||
|
artifact.target.kind.includes('bin') &&
|
||||||
|
artifact.executable
|
||||||
|
) {
|
||||||
|
artifacts.set(artifact.target.name, resolve(artifact.executable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const executables = [
|
||||||
|
'clash-verge-service',
|
||||||
|
'clash-verge-service-install',
|
||||||
|
'clash-verge-service-uninstall',
|
||||||
|
'service-integration-driver',
|
||||||
|
].map((name) => {
|
||||||
|
const executable = artifacts.get(name)
|
||||||
|
if (!executable)
|
||||||
|
throw new Error(`Cargo did not report the ${name} executable`)
|
||||||
|
return executable
|
||||||
|
})
|
||||||
|
const serviceDirectory = dirname(executables[0])
|
||||||
|
for (const executable of executables) {
|
||||||
|
if (dirname(executable) !== serviceDirectory) {
|
||||||
|
throw new Error(
|
||||||
|
'Development service tools were built in different directories',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await access(executable)
|
||||||
|
}
|
||||||
|
return serviceDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureDevelopmentService({
|
export async function ensureDevelopmentService({
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const serviceDirectory = await prepareDevelopmentService({ platform })
|
const serviceDirectory = await prepareDevelopmentService()
|
||||||
const service = join(
|
const extension = platform === 'win32' ? '.exe' : ''
|
||||||
serviceDirectory,
|
|
||||||
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
|
|
||||||
)
|
|
||||||
const installer = join(
|
const installer = join(
|
||||||
serviceDirectory,
|
serviceDirectory,
|
||||||
platform === 'win32'
|
`clash-verge-service-install${extension}`,
|
||||||
? 'clash-verge-service-install.exe'
|
|
||||||
: 'clash-verge-service-install',
|
|
||||||
)
|
)
|
||||||
const driver = join(
|
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
|
||||||
serviceDirectory,
|
/^host: (.+)$/m,
|
||||||
platform === 'win32'
|
)?.[1]
|
||||||
? 'service-integration-driver.exe'
|
if (!host) throw new Error('rustc did not report its host target')
|
||||||
: 'service-integration-driver',
|
const args = ['--prepare-install', '--ensure']
|
||||||
)
|
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
|
||||||
const fingerprint = await hashFiles([service, installer])
|
args.push(
|
||||||
const installedFingerprint = await readInstalledFingerprint()
|
'--core',
|
||||||
|
`${name}${extension}`,
|
||||||
if (installedFingerprint === fingerprint) {
|
join(
|
||||||
try {
|
repositoryRoot,
|
||||||
await run(driver, ['probe'], { stdio: 'ignore' })
|
'src-tauri',
|
||||||
return serviceDirectory
|
'sidecar',
|
||||||
} catch {
|
`${name}-${host}${extension}`,
|
||||||
// The expected binary is installed but unavailable; the repair tool owns recovery.
|
),
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
await run(installer, args)
|
||||||
try {
|
|
||||||
await elevateInstaller(installer, platform)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.exitCode !== 75) throw error
|
|
||||||
}
|
|
||||||
await run(driver, ['ready'])
|
|
||||||
await writeInstalledFingerprint(fingerprint)
|
|
||||||
return serviceDirectory
|
return serviceDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from './dev-control.mjs'
|
} from './dev-control.mjs'
|
||||||
import {
|
import {
|
||||||
developmentServiceDirectoryEnvironment,
|
developmentServiceDirectoryEnvironment,
|
||||||
|
developmentServiceWatchPaths,
|
||||||
ensureDevelopmentService,
|
ensureDevelopmentService,
|
||||||
prepareDevelopmentService,
|
prepareDevelopmentService,
|
||||||
} from './dev-service.mjs'
|
} from './dev-service.mjs'
|
||||||
@@ -31,6 +32,9 @@ export function buildTauriInvocation(
|
|||||||
env.RUSTFLAGS = '--cfg tokio_unstable'
|
env.RUSTFLAGS = '--cfg tokio_unstable'
|
||||||
args.push('tokio-trace')
|
args.push('tokio-trace')
|
||||||
}
|
}
|
||||||
|
for (const path of developmentServiceWatchPaths) {
|
||||||
|
args.push('--additional-watch-folders', path)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'pnpm',
|
command: 'pnpm',
|
||||||
args,
|
args,
|
||||||
|
|||||||
+53
-11
@@ -1,9 +1,9 @@
|
|||||||
import { execFileSync, execSync } from 'child_process'
|
import { execFileSync, execSync } from 'node:child_process'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import fs from 'fs'
|
import fs from 'node:fs'
|
||||||
import fsp from 'fs/promises'
|
import fsp from 'node:fs/promises'
|
||||||
import path from 'path'
|
import path from 'node:path'
|
||||||
import zlib from 'zlib'
|
import zlib from 'node:zlib'
|
||||||
|
|
||||||
import AdmZip from 'adm-zip'
|
import AdmZip from 'adm-zip'
|
||||||
import { glob } from 'glob'
|
import { glob } from 'glob'
|
||||||
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
|
|||||||
const zip = new AdmZip(tempZip)
|
const zip = new AdmZip(tempZip)
|
||||||
zip
|
zip
|
||||||
.getEntries()
|
.getEntries()
|
||||||
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
|
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||||
zip.extractAllTo(tempDir, true)
|
zip.extractAllTo(tempDir, true)
|
||||||
if (fs.existsSync(tempDll)) {
|
if (fs.existsSync(tempDll)) {
|
||||||
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
||||||
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
|
|||||||
|
|
||||||
function serviceFileInfo(name) {
|
function serviceFileInfo(name) {
|
||||||
const ext = platform === 'win32' ? '.exe' : ''
|
const ext = platform === 'win32' ? '.exe' : ''
|
||||||
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
|
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
|
||||||
return {
|
return {
|
||||||
sourceFile: `${name}${ext}`,
|
sourceFile: `${name}${ext}`,
|
||||||
targetFile: `${name}${suffix}${ext}`,
|
targetFile: `${name}${suffix}${ext}`,
|
||||||
@@ -586,6 +586,41 @@ async function resolveServiceBundle() {
|
|||||||
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
|
const serviceDependency = cargoManifest
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
|
||||||
|
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
|
||||||
|
if (sourcePath) {
|
||||||
|
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
|
||||||
|
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
|
||||||
|
execFileSync(
|
||||||
|
'cargo',
|
||||||
|
[
|
||||||
|
'build',
|
||||||
|
'--manifest-path',
|
||||||
|
manifest,
|
||||||
|
'--target-dir',
|
||||||
|
targetDirectory,
|
||||||
|
'--target',
|
||||||
|
SIDECAR_HOST,
|
||||||
|
'--release',
|
||||||
|
'--features',
|
||||||
|
'standalone,client',
|
||||||
|
'--bins',
|
||||||
|
],
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
)
|
||||||
|
await fsp.mkdir(SERVICE_DIR, { recursive: true })
|
||||||
|
for (const { sourceFile, targetPath } of files) {
|
||||||
|
await fsp.copyFile(
|
||||||
|
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
|
||||||
|
targetPath,
|
||||||
|
)
|
||||||
|
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
|
||||||
|
await updateHashCache(targetPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const { archiveFile, downloadURL } = resolveServiceRelease(
|
const { archiveFile, downloadURL } = resolveServiceRelease(
|
||||||
cargoManifest,
|
cargoManifest,
|
||||||
SIDECAR_HOST,
|
SIDECAR_HOST,
|
||||||
@@ -604,8 +639,9 @@ async function resolveServiceBundle() {
|
|||||||
const zip = new AdmZip(tempArchive)
|
const zip = new AdmZip(tempArchive)
|
||||||
zip
|
zip
|
||||||
.getEntries()
|
.getEntries()
|
||||||
.forEach((entry) =>
|
.forEach(
|
||||||
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
(entry) =>
|
||||||
|
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||||
)
|
)
|
||||||
zip.extractAllTo(tempDir, true)
|
zip.extractAllTo(tempDir, true)
|
||||||
} else {
|
} else {
|
||||||
@@ -652,7 +688,7 @@ async function resolveCoreHashes() {
|
|||||||
.digest('hex')
|
.digest('hex')
|
||||||
lines.push(`!define ${define} "${digest}"`)
|
lines.push(`!define ${define} "${digest}"`)
|
||||||
}
|
}
|
||||||
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
|
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
|
||||||
log_success(`Generated ${CORE_HASHES_NSH}`)
|
log_success(`Generated ${CORE_HASHES_NSH}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +697,11 @@ const resolveMmdb = () =>
|
|||||||
file: 'Country.mmdb',
|
file: 'Country.mmdb',
|
||||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
||||||
})
|
})
|
||||||
|
const resolveASNMmdb = () =>
|
||||||
|
resolveResource({
|
||||||
|
file: 'ASN.mmdb',
|
||||||
|
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
|
||||||
|
})
|
||||||
const resolveGeosite = () =>
|
const resolveGeosite = () =>
|
||||||
resolveResource({
|
resolveResource({
|
||||||
file: 'geosite.dat',
|
file: 'geosite.dat',
|
||||||
@@ -706,6 +747,7 @@ const tasks = [
|
|||||||
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
||||||
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
||||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||||
|
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
|
||||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||||
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
||||||
{
|
{
|
||||||
|
|||||||
+50
-15
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
|
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm release-version <version>
|
* pnpm release-version <version>
|
||||||
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
|
|||||||
'[INFO]: Current package.json version is: ',
|
'[INFO]: Current package.json version is: ',
|
||||||
packageJson.version,
|
packageJson.version,
|
||||||
)
|
)
|
||||||
packageJson.version = newVersion.startsWith('v')
|
const versionWithoutV = newVersion.startsWith('v')
|
||||||
? newVersion.slice(1)
|
? newVersion.slice(1)
|
||||||
: newVersion
|
: newVersion
|
||||||
await fs.writeFile(
|
const updatedData = data.replace(
|
||||||
packageJsonPath,
|
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||||
JSON.stringify(packageJson, null, 2),
|
`$1"${versionWithoutV}"`,
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
console.log(
|
|
||||||
`[INFO]: package.json version updated to: ${packageJson.version}`,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (updatedData === data) {
|
||||||
|
throw new Error('version field was not found in package.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
|
||||||
|
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating package.json version:', error)
|
console.error('Error updating package.json version:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -153,6 +156,34 @@ async function updateCargoVersion(newVersion) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCargoLockVersion(newVersion) {
|
||||||
|
const _dirname = process.cwd()
|
||||||
|
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
|
||||||
|
const versionWithoutV = newVersion.startsWith('v')
|
||||||
|
? newVersion.slice(1)
|
||||||
|
: newVersion
|
||||||
|
const packageVersionPattern =
|
||||||
|
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(cargoLockPath, 'utf8')
|
||||||
|
const updatedData = data.replace(
|
||||||
|
packageVersionPattern,
|
||||||
|
`$1"${versionWithoutV}"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (updatedData === data) {
|
||||||
|
throw new Error('clash-verge package entry was not found in Cargo.lock')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
|
||||||
|
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating Cargo.lock version:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateTauriConfigVersion(newVersion) {
|
async function updateTauriConfigVersion(newVersion) {
|
||||||
const _dirname = process.cwd()
|
const _dirname = process.cwd()
|
||||||
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
||||||
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
|
|||||||
tauriConfig.version,
|
tauriConfig.version,
|
||||||
)
|
)
|
||||||
|
|
||||||
tauriConfig.version = versionWithoutV
|
const updatedData = data.replace(
|
||||||
|
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||||
await fs.writeFile(
|
`$1"${versionWithoutV}"`,
|
||||||
tauriConfigPath,
|
|
||||||
JSON.stringify(tauriConfig, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (updatedData === data) {
|
||||||
|
throw new Error('version field was not found in tauri.conf.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
|
||||||
console.log(
|
console.log(
|
||||||
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
||||||
)
|
)
|
||||||
@@ -239,6 +273,7 @@ async function main(versionArg) {
|
|||||||
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
||||||
await updatePackageVersion(newVersion)
|
await updatePackageVersion(newVersion)
|
||||||
await updateCargoVersion(newVersion)
|
await updateCargoVersion(newVersion)
|
||||||
|
await updateCargoLockVersion(newVersion)
|
||||||
await updateTauriConfigVersion(newVersion)
|
await updateTauriConfigVersion(newVersion)
|
||||||
console.log('[SUCCESS]: All version updates completed successfully!')
|
console.log('[SUCCESS]: All version updates completed successfully!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -14,22 +14,13 @@ async function resolveUpdater() {
|
|||||||
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
||||||
const github = getOctokit(process.env.GITHUB_TOKEN)
|
const github = getOctokit(process.env.GITHUB_TOKEN)
|
||||||
|
|
||||||
const { data: tags } = await github.rest.repos.listTags({
|
const { data: latestRelease } =
|
||||||
...options,
|
await github.rest.repos.getLatestRelease(options)
|
||||||
per_page: 10,
|
const tag = { name: latestRelease.tag_name }
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tag = tags.find((t) => t.name.startsWith('v'))
|
|
||||||
|
|
||||||
console.log(tag)
|
console.log(tag)
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
|
|
||||||
...options,
|
|
||||||
tag: tag.name,
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
||||||
|
|||||||
+3
-2
@@ -40,10 +40,11 @@ async function resolveUpdater() {
|
|||||||
const tags = allTags
|
const tags = allTags
|
||||||
console.log(`Retrieved ${tags.length} tags in total`)
|
console.log(`Retrieved ${tags.length} tags in total`)
|
||||||
|
|
||||||
const stableTagRegex = /^v\d+\.\d+\.\d+$/
|
|
||||||
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
||||||
|
|
||||||
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
|
const { data: latestRelease } =
|
||||||
|
await github.rest.repos.getLatestRelease(options)
|
||||||
|
const stableTag = { name: latestRelease.tag_name }
|
||||||
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
|
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
|
||||||
|
|
||||||
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
||||||
|
|||||||
+18
-20
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.5.4"
|
version = "2.5.7"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -59,13 +59,13 @@ smartstring = { workspace = true, features = ["serde"] }
|
|||||||
bitflags = { workspace = true }
|
bitflags = { workspace = true }
|
||||||
socket2 = { workspace = true }
|
socket2 = { workspace = true }
|
||||||
warp = { version = "0.4.3", features = ["server"] }
|
warp = { version = "0.4.3", features = ["server"] }
|
||||||
open = "5.4.1"
|
open = "5.4.4"
|
||||||
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
nanoid = "0.5"
|
nanoid = "0.5"
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
boa_engine = "0.21.1"
|
boa_engine = "0.22.0"
|
||||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
@@ -74,37 +74,37 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
|||||||
"guard",
|
"guard",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.6"
|
||||||
tauri-plugin-opener = "2.5.4"
|
tauri-plugin-opener = "2.5.5"
|
||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.3"
|
||||||
tauri-plugin-fs = "2.5.1"
|
tauri-plugin-fs = "2.5.2"
|
||||||
tauri-plugin-process = "2.3.1"
|
tauri-plugin-process = "2.3.1"
|
||||||
tauri-plugin-deep-link = "2.4.9"
|
tauri-plugin-deep-link = "2.4.10"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.10"
|
||||||
reqwest_dav = "0.3.3"
|
reqwest_dav = "0.3.3"
|
||||||
aes-gcm = { version = "0.11.0" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.3.3"
|
tauri-plugin-notification = "2.4.0"
|
||||||
tokio-stream = "0.1.19"
|
tokio-stream = "0.1.19"
|
||||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||||
tauri-plugin-http = "2.5.9"
|
tauri-plugin-http = "2.6.1"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
tauri-plugin-devtools = { version = "2.2.1", optional = true }
|
||||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", 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",
|
"client",
|
||||||
] }
|
] }
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
tokio-rustls = "0.26.4"
|
tokio-rustls = "0.26.5"
|
||||||
rustls = { version = "0.23.43", features = ["ring"] }
|
rustls = { version = "0.23.45", features = ["ring"] }
|
||||||
webpki-roots = "1.0.9"
|
webpki-roots = "1.0.9"
|
||||||
dark-light = "3.0.0"
|
dark-light = "3.0.0"
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
@@ -114,9 +114,7 @@ tokio-util = { version = "0.7.19", features = ["time"] }
|
|||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||||
core-foundation = "0.9"
|
|
||||||
system-configuration = "0.7"
|
|
||||||
objc2 = "0.6.4"
|
objc2 = "0.6.4"
|
||||||
objc2-foundation = { version = "0.3.2", features = [
|
objc2-foundation = { version = "0.3.2", features = [
|
||||||
"NSString",
|
"NSString",
|
||||||
@@ -150,7 +148,7 @@ windows-sys.workspace = true
|
|||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.5.1"
|
tauri-plugin-autostart = "2.5.1"
|
||||||
tauri-plugin-global-shortcut = "2.3.2"
|
tauri-plugin-global-shortcut = "2.3.2"
|
||||||
tauri-plugin-updater = "2.10.1"
|
tauri-plugin-updater = "2.12.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
chmod +x /usr/bin/clash-verge-service-install
|
||||||
|
chmod +x /usr/bin/clash-verge-service-uninstall
|
||||||
|
chmod +x /usr/bin/clash-verge-service
|
||||||
|
|
||||||
|
# The enforcement file exists in both Enforcing and Permissive mode.
|
||||||
|
if [ ! -e /sys/fs/selinux/enforce ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
|
||||||
|
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
|
||||||
|
for service in clash-verge-service clash-verge-service-dev; do
|
||||||
|
for directory in bin cores; do
|
||||||
|
target="/var/lib/$service/$directory"
|
||||||
|
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
|
||||||
|
echo "Refusing to relabel a symlinked service directory: $target" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
spec="$target(/.*)?"
|
||||||
|
# Older semanage versions reject -a when an exact rule already exists.
|
||||||
|
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
|
||||||
|
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
|
||||||
|
printf '%s\n' "$add_error" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -d "$target" ]; then
|
||||||
|
/usr/sbin/restorecon -R "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
@@ -133,8 +133,12 @@ pub fn take_dns_override_notice() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
|
pub async fn set_dns_override(
|
||||||
feat::set_dns_override(enabled, confirmation)
|
profile_uid: String,
|
||||||
|
enabled: bool,
|
||||||
|
confirmation: Option<String>,
|
||||||
|
) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||||
|
feat::set_dns_override(profile_uid, enabled, confirmation)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
||||||
}
|
}
|
||||||
@@ -145,16 +149,26 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
|||||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||||
|
|
||||||
if !dns_path.exists() {
|
if !dns_path.exists() {
|
||||||
logging!(warn, Type::Config, "DNS config file not found");
|
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
|
||||||
return Err("DNS config file not found".into());
|
return Err("DNS config file not found".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
||||||
logging!(error, Type::Config, "Failed to read DNS config: {e}");
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Config,
|
||||||
|
"Failed to read DNS config {}: {e}",
|
||||||
|
dns_path.display()
|
||||||
|
);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||||
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
|
logging!(
|
||||||
|
error,
|
||||||
|
Type::Config,
|
||||||
|
"Failed to parse DNS config {}: {e}",
|
||||||
|
dns_path.display()
|
||||||
|
);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut patch = serde_yaml_ng::Mapping::new();
|
let mut patch = serde_yaml_ng::Mapping::new();
|
||||||
@@ -210,7 +224,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
|||||||
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
|
// A fragment, not a runnable config; the merged result is validated on apply.
|
||||||
|
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
|
||||||
.await
|
.await
|
||||||
.stringify_err()
|
.stringify_err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
|
|||||||
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn take_discarded_keys_notice() -> Option<String> {
|
||||||
|
crate::enhance::take_discarded_keys_notice()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
||||||
let runtime = Config::runtime().await;
|
let runtime = Config::runtime().await;
|
||||||
|
|||||||
+162
-17
@@ -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 manager = CoreManager::global();
|
||||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
let result = {
|
||||||
if matches!(
|
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||||
&status,
|
if matches!(
|
||||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
&status,
|
||||||
) {
|
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||||
manager
|
) {
|
||||||
.controlled_stop_core_inner()
|
manager
|
||||||
.await
|
.controlled_stop_core_inner()
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
.await
|
||||||
}
|
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||||
SERVICE_MANAGER
|
}
|
||||||
.handle_service_status(status)
|
SERVICE_MANAGER.handle_service_status(status).await
|
||||||
.await
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
result
|
||||||
|
.map(|()| ServiceInstallOutcome::Installed)
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
async fn finish_service_installation<Fallback, FallbackFuture>(
|
||||||
|
installation: anyhow::Result<()>,
|
||||||
|
error_code: &str,
|
||||||
|
fallback: Fallback,
|
||||||
|
) -> CmdResult<ServiceInstallOutcome>
|
||||||
|
where
|
||||||
|
Fallback: FnOnce() -> FallbackFuture,
|
||||||
|
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
||||||
|
{
|
||||||
|
let Err(error) = installation else {
|
||||||
|
return Ok(ServiceInstallOutcome::Installed);
|
||||||
|
};
|
||||||
|
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
|
||||||
|
else {
|
||||||
|
return Err(proxy_aware_coded_error(&error, error_code));
|
||||||
|
};
|
||||||
|
let reasons = failure
|
||||||
|
.status
|
||||||
|
.cores
|
||||||
|
.iter()
|
||||||
|
.filter_map(|core| match &core.availability {
|
||||||
|
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if reasons.is_empty() {
|
||||||
|
return Err(proxy_aware_coded_error(&error, error_code));
|
||||||
|
}
|
||||||
|
let reason = reasons.join("\n");
|
||||||
|
fallback().await.map_err(|error| {
|
||||||
|
proxy_aware_coded_error(
|
||||||
|
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||||
|
"SERVICE_SIDECAR_FAILED",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ServiceInstallOutcome::Sidecar { reason })
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +95,12 @@ pub async fn uninstall_service() -> CmdResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +112,99 @@ pub async fn continue_with_sidecar() -> CmdResult {
|
|||||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[tauri::command]
|
||||||
pub fn sync_runtime_providers() {
|
pub fn sync_runtime_providers() {
|
||||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||||
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
||||||
|
mod tests {
|
||||||
|
use super::{ServiceInstallOutcome, finish_service_installation};
|
||||||
|
use clash_verge_service_ipc::{
|
||||||
|
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
|
||||||
|
};
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
|
||||||
|
let reason = "core path C:\\ is writable by Everyone";
|
||||||
|
for (availability, fallback_ready) in [
|
||||||
|
(None, false),
|
||||||
|
(Some(CoreAvailability::Missing), false),
|
||||||
|
(Some(CoreAvailability::DigestMismatch), false),
|
||||||
|
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
|
||||||
|
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
|
||||||
|
] {
|
||||||
|
let fallback_called = Cell::new(false);
|
||||||
|
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
|
||||||
|
let error = match availability {
|
||||||
|
Some(availability) => anyhow::Error::new(InstallationVerificationError {
|
||||||
|
status: InstallationStatus {
|
||||||
|
service_sha256: String::new(),
|
||||||
|
protocol: ProtocolInfo::current(),
|
||||||
|
cores: vec![CoreInspection {
|
||||||
|
name: "verge-mihomo.exe".into(),
|
||||||
|
availability,
|
||||||
|
}],
|
||||||
|
core_busy: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
None => anyhow::anyhow!("{reason}"),
|
||||||
|
}
|
||||||
|
.context("install service failed");
|
||||||
|
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
|
||||||
|
fallback_called.set(true);
|
||||||
|
anyhow::ensure!(fallback_ready, "another core is running");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(fallback_called.get(), rejected);
|
||||||
|
if rejected && fallback_ready {
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(ServiceInstallOutcome::Sidecar {
|
||||||
|
reason: format!("verge-mihomo.exe: {reason}")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let error = result.expect_err("an unavailable fallback must remain an error");
|
||||||
|
assert_eq!(
|
||||||
|
error.code.as_deref(),
|
||||||
|
Some(if rejected {
|
||||||
|
"SERVICE_SIDECAR_FAILED"
|
||||||
|
} else {
|
||||||
|
"SERVICE_INSTALL_FAILED"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if rejected {
|
||||||
|
assert!(error.detail.contains(reason));
|
||||||
|
assert!(error.detail.contains("another core is running"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
CoreManager,
|
CoreManager,
|
||||||
handle::{self, Handle},
|
handle::{self, Handle},
|
||||||
listener::MIXED_PORT_KEY,
|
listener::MIXED_PORT_KEY,
|
||||||
|
runtime_bundle::resolve_provider_path_conflicts,
|
||||||
tray,
|
tray,
|
||||||
validate::CoreConfigValidator,
|
validate::CoreConfigValidator,
|
||||||
},
|
},
|
||||||
@@ -228,6 +229,7 @@ impl Config {
|
|||||||
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
|
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
|
||||||
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
|
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
|
||||||
|
|
||||||
|
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
|
||||||
sanitize_tunnels_proxy(&mut config);
|
sanitize_tunnels_proxy(&mut config);
|
||||||
// Apply only to generated core config so the saved choice survives the next launch.
|
// Apply only to generated core config so the saved choice survives the next launch.
|
||||||
if let Some(port) = MixedPort::session_fallback() {
|
if let Some(port) = MixedPort::session_fallback() {
|
||||||
|
|||||||
+133
-35
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
@@ -54,6 +55,7 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct DnsOverrideState {
|
pub(crate) struct DnsOverrideState {
|
||||||
|
profile_uid: String,
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
requested: bool,
|
requested: bool,
|
||||||
@@ -61,9 +63,10 @@ pub(crate) struct DnsOverrideState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DnsOverrideState {
|
impl DnsOverrideState {
|
||||||
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
||||||
let enabled = requested && (source.is_none() || source == confirmation);
|
let enabled = requested && (source.is_none() || source == confirmation);
|
||||||
Self {
|
Self {
|
||||||
|
profile_uid: profile_uid.into(),
|
||||||
source,
|
source,
|
||||||
enabled,
|
enabled,
|
||||||
requested,
|
requested,
|
||||||
@@ -72,24 +75,55 @@ impl DnsOverrideState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_to(&self, verge: &mut IVerge) -> bool {
|
fn apply_to(&self, verge: &mut IVerge) -> bool {
|
||||||
|
if self.profile_uid.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let settings = verge.dns_settings_for(&self.profile_uid);
|
||||||
// A later settings write must not be overwritten by an earlier runtime generation.
|
// A later settings write must not be overwritten by an earlier runtime generation.
|
||||||
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
|
||||||
|| verge.dns_override_confirmation != self.confirmation
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
|
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
|
||||||
if self.enabled == self.requested && !clear_confirmation {
|
if self.enabled == self.requested
|
||||||
|
&& !clear_confirmation
|
||||||
|
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
verge.enable_dns_settings = Some(self.enabled);
|
verge.profile_dns_settings.insert(
|
||||||
if clear_confirmation {
|
self.profile_uid.clone(),
|
||||||
verge.dns_override_confirmation = None;
|
ProfileDnsSettings {
|
||||||
}
|
enabled: self.enabled,
|
||||||
|
confirmation: if clear_confirmation {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.confirmation.clone()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
|
pub struct ProfileDnsSettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub confirmation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IVerge {
|
||||||
|
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
|
||||||
|
self.profile_dns_settings
|
||||||
|
.get(profile_uid)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(ProfileDnsSettings {
|
||||||
|
enabled: self.enable_dns_settings.unwrap_or(false),
|
||||||
|
confirmation: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub(crate) async fn sync_dns_override() -> Result<()> {
|
pub(crate) async fn sync_dns_override() -> Result<()> {
|
||||||
let _config_write = Self::lock_config_write().await;
|
let _config_write = Self::lock_config_write().await;
|
||||||
@@ -116,7 +150,7 @@ impl Config {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{DnsOverrideState, dns_override_source};
|
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
|
||||||
use crate::config::IVerge;
|
use crate::config::IVerge;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
@@ -152,35 +186,46 @@ mod tests {
|
|||||||
let reordered =
|
let reordered =
|
||||||
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
|
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
|
||||||
assert_eq!(source, dns_override_source("one", &reordered)?);
|
assert_eq!(source, dns_override_source("one", &reordered)?);
|
||||||
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
|
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
|
||||||
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
|
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
|
||||||
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
|
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
|
||||||
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
|
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 source = Some("provider-dns".into());
|
||||||
let confirmed = IVerge {
|
let confirmed = IVerge {
|
||||||
enable_dns_settings: Some(true),
|
profile_dns_settings: [(
|
||||||
dns_override_confirmation: source.clone(),
|
"one".into(),
|
||||||
|
ProfileDnsSettings {
|
||||||
|
enabled: true,
|
||||||
|
confirmation: source.clone(),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into(),
|
||||||
..IVerge::default()
|
..IVerge::default()
|
||||||
};
|
};
|
||||||
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
|
let saved = serde_yaml_ng::to_string(&confirmed)?;
|
||||||
for saved in [
|
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||||
serde_yaml_ng::to_string(&confirmed)?,
|
let settings = restarted.dns_settings_for("one");
|
||||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
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 mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||||
let state = DnsOverrideState::new(
|
let settings = restarted.dns_settings_for("one");
|
||||||
source.clone(),
|
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
|
||||||
restarted.enable_dns_settings.unwrap_or(false),
|
|
||||||
restarted.dns_override_confirmation.clone(),
|
|
||||||
);
|
|
||||||
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
||||||
assert!(state.apply_to(&mut restarted));
|
assert!(state.apply_to(&mut restarted));
|
||||||
assert_eq!(restarted.enable_dns_settings, Some(false));
|
assert!(!restarted.dns_settings_for("one").enabled);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -191,21 +236,74 @@ mod tests {
|
|||||||
enable_dns_settings: Some(true),
|
enable_dns_settings: Some(true),
|
||||||
..IVerge::default()
|
..IVerge::default()
|
||||||
};
|
};
|
||||||
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
|
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
|
||||||
assert!(state.apply_to(&mut verge));
|
assert!(state.apply_to(&mut verge));
|
||||||
assert_eq!(verge.enable_dns_settings, Some(false));
|
assert!(!verge.dns_settings_for("one").enabled);
|
||||||
assert!(!state.apply_to(&mut verge));
|
assert!(!state.apply_to(&mut verge));
|
||||||
let disabled = DnsOverrideState::new(None, false, None);
|
let disabled = DnsOverrideState::new("one", None, false, None);
|
||||||
assert!(!disabled.apply_to(&mut verge));
|
assert!(!disabled.apply_to(&mut verge));
|
||||||
assert!(!disabled.enabled);
|
assert!(!disabled.enabled);
|
||||||
|
|
||||||
verge.enable_dns_settings = Some(true);
|
verge.profile_dns_settings.insert(
|
||||||
verge.dns_override_confirmation = Some("provider-dns".into());
|
"one".into(),
|
||||||
|
ProfileDnsSettings {
|
||||||
|
enabled: true,
|
||||||
|
confirmation: Some("provider-dns".into()),
|
||||||
|
},
|
||||||
|
);
|
||||||
assert!(!state.apply_to(&mut verge));
|
assert!(!state.apply_to(&mut verge));
|
||||||
assert_eq!(verge.enable_dns_settings, Some(true));
|
let settings = verge.dns_settings_for("one");
|
||||||
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
|
assert!(settings.enabled);
|
||||||
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
|
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
|
||||||
|
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
|
||||||
assert!(leaving.apply_to(&mut verge));
|
assert!(leaving.apply_to(&mut verge));
|
||||||
assert!(verge.dns_override_confirmation.is_none());
|
assert!(verge.dns_settings_for("one").confirmation.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
|
||||||
|
let mut verge = IVerge {
|
||||||
|
enable_dns_settings: Some(true),
|
||||||
|
..IVerge::default()
|
||||||
|
};
|
||||||
|
let first = DnsOverrideState::new("one", None, true, None);
|
||||||
|
assert!(first.apply_to(&mut verge));
|
||||||
|
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
|
||||||
|
assert!(protected.apply_to(&mut verge));
|
||||||
|
assert!(verge.dns_settings_for("one").enabled);
|
||||||
|
assert!(!verge.dns_settings_for("two").enabled);
|
||||||
|
assert!(verge.dns_settings_for("unvisited").enabled);
|
||||||
|
assert_eq!(verge.enable_dns_settings, Some(true));
|
||||||
|
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
|
||||||
|
assert!(restarted.dns_settings_for("one").enabled);
|
||||||
|
assert!(!restarted.dns_settings_for("two").enabled);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
|
||||||
|
let mut verge = IVerge::default();
|
||||||
|
for uid in ["one", "two"] {
|
||||||
|
verge.profile_dns_settings.insert(
|
||||||
|
uid.into(),
|
||||||
|
ProfileDnsSettings {
|
||||||
|
enabled: true,
|
||||||
|
confirmation: Some(uid.into()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for uid in ["one", "two", "one"] {
|
||||||
|
let settings = verge.dns_settings_for(uid);
|
||||||
|
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
|
||||||
|
assert!(state.enabled);
|
||||||
|
assert!(!state.apply_to(&mut verge));
|
||||||
|
}
|
||||||
|
let settings = verge.dns_settings_for("two");
|
||||||
|
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
|
||||||
|
assert!(updated.apply_to(&mut verge));
|
||||||
|
assert!(!verge.dns_settings_for("two").enabled);
|
||||||
|
assert!(verge.dns_settings_for("two").confirmation.is_none());
|
||||||
|
assert!(verge.dns_settings_for("one").enabled);
|
||||||
|
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,14 @@ impl ProfileDeletePlan {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for file in self.files {
|
for file in self.files {
|
||||||
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
|
let path = dir.join(file.as_str());
|
||||||
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
|
if let Err(error) = path.remove_if_exists().await {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Config,
|
||||||
|
"Failed to remove deleted profile file {}: {error:#}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,12 +80,11 @@ pub struct IVerge {
|
|||||||
|
|
||||||
pub enable_bypass_check: Option<bool>,
|
pub enable_bypass_check: Option<bool>,
|
||||||
|
|
||||||
/// enable dns settings - this controls whether dns_config.yaml is applied
|
/// Initial DNS override preference for profiles without a saved setting.
|
||||||
pub enable_dns_settings: Option<bool>,
|
pub enable_dns_settings: Option<bool>,
|
||||||
|
|
||||||
// Force-enable confirmation is valid only for the current app session.
|
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||||
#[serde(skip)]
|
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
|
||||||
pub dns_override_confirmation: Option<String>,
|
|
||||||
|
|
||||||
pub use_default_bypass: Option<bool>,
|
pub use_default_bypass: Option<bool>,
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ impl Hotkey {
|
|||||||
HotkeyFunction::Hide => {
|
HotkeyFunction::Hide => {
|
||||||
AsyncHandler::spawn(async move || {
|
AsyncHandler::spawn(async move || {
|
||||||
feat::hide().await;
|
feat::hide().await;
|
||||||
notify_event(NotificationEvent::AppHidden).await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
|
|||||||
| ServiceStatus::NeedsReinstall
|
| ServiceStatus::NeedsReinstall
|
||||||
| ServiceStatus::InstallRequired
|
| ServiceStatus::InstallRequired
|
||||||
| ServiceStatus::Unavailable(_)
|
| ServiceStatus::Unavailable(_)
|
||||||
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
|
| ServiceStatus::SidecarAllowed
|
||||||
|
) | (
|
||||||
|
RunningMode::Sidecar,
|
||||||
|
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,18 +189,60 @@ where
|
|||||||
apply_proxy().await
|
apply_proxy().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
|
||||||
|
start_service: Start,
|
||||||
|
confirm_idle: Check,
|
||||||
|
start_sidecar: Fallback,
|
||||||
|
record_unavailable: impl FnOnce(std::string::String),
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
Start: FnOnce() -> StartFuture,
|
||||||
|
StartFuture: std::future::Future<Output = Result<()>>,
|
||||||
|
Check: FnOnce() -> CheckFuture,
|
||||||
|
CheckFuture: std::future::Future<Output = Result<()>>,
|
||||||
|
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
|
||||||
|
FallbackFuture: std::future::Future<Output = Result<()>>,
|
||||||
|
{
|
||||||
|
let Err(error) = start_service().await else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
|
||||||
|
let location_refused = refusal.is_some_and(|refusal| {
|
||||||
|
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
|
||||||
|
});
|
||||||
|
if refusal.is_some() && !location_refused {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
// A lost start response can hide a running core; the shared check must resolve occupancy.
|
||||||
|
if let Err(probe_error) = confirm_idle().await {
|
||||||
|
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
|
||||||
|
record_unavailable(format!("{error:#}"));
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
"Service could not start the core; falling back to Sidecar: {error:#}"
|
||||||
|
);
|
||||||
|
let reason = format!("{error:#}");
|
||||||
|
start_sidecar(error)
|
||||||
|
.await
|
||||||
|
.map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
|
||||||
clear_proxy: Clear,
|
clear_proxy: Clear,
|
||||||
terminate_sidecar: Terminate,
|
terminate_sidecar: Terminate,
|
||||||
) -> Result<()>
|
) -> Result<()>
|
||||||
where
|
where
|
||||||
Clear: FnOnce() -> ClearFuture,
|
Clear: FnOnce() -> ClearFuture,
|
||||||
ClearFuture: std::future::Future<Output = Result<()>>,
|
ClearFuture: std::future::Future<Output = Result<()>>,
|
||||||
Terminate: FnOnce(),
|
Terminate: FnOnce() -> TerminateFuture,
|
||||||
|
TerminateFuture: std::future::Future<Output = Result<()>>,
|
||||||
{
|
{
|
||||||
clear_proxy().await?;
|
clear_proxy().await?;
|
||||||
terminate_sidecar();
|
terminate_sidecar().await
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_service_config_replacement_transition<
|
async fn run_service_config_replacement_transition<
|
||||||
@@ -335,7 +381,13 @@ impl CoreManager {
|
|||||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||||
}
|
}
|
||||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
#[cfg(target_os = "windows")]
|
||||||
|
if matches!(*mode, RunningMode::NotRunning) {
|
||||||
|
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||||
|
}
|
||||||
|
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.
|
// 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.
|
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||||
let prepared = async {
|
let prepared = async {
|
||||||
@@ -476,10 +528,7 @@ impl CoreManager {
|
|||||||
// rather than fail — only if the watcher is actually live; a user toggling while
|
// rather than fail — only if the watcher is actually live; a user toggling while
|
||||||
// offline still fails fast.
|
// offline still fails fast.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let watcher_armed = crate::core::network_watch::is_armed();
|
if crate::core::network_watch::is_armed()
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
let watcher_armed = false;
|
|
||||||
if watcher_armed
|
|
||||||
&& !crate::utils::resolve::is_resolve_done()
|
&& !crate::utils::resolve::is_resolve_done()
|
||||||
&& Config::verge()
|
&& Config::verge()
|
||||||
.await
|
.await
|
||||||
@@ -545,6 +594,8 @@ impl CoreManager {
|
|||||||
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
tracing::Span::current().record("decision", tracing::field::debug(&startup));
|
||||||
if matches!(startup, StartupDecision::Wait) {
|
if matches!(startup, StartupDecision::Wait) {
|
||||||
self.rollback_failed_start().await;
|
self.rollback_failed_start().await;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
self.try_sidecar_after_service_unavailable().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if Handle::global().is_exiting() {
|
if Handle::global().is_exiting() {
|
||||||
@@ -553,7 +604,23 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = match startup {
|
let result = match startup {
|
||||||
StartupDecision::Service => self.start_core_by_service().await,
|
StartupDecision::Service => {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
run_service_start_with_sidecar_fallback(
|
||||||
|
|| self.start_core_by_service(),
|
||||||
|
clash_verge_service_ipc::execution::check_sidecar_available,
|
||||||
|
|error| self.start_sidecar_after_service_failure(error),
|
||||||
|
|reason| {
|
||||||
|
crate::core::runstate::RUN_STATE
|
||||||
|
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
self.start_core_by_service().await
|
||||||
|
}
|
||||||
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
|
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
|
||||||
StartupDecision::Wait => Ok(()),
|
StartupDecision::Wait => Ok(()),
|
||||||
};
|
};
|
||||||
@@ -572,6 +639,50 @@ impl CoreManager {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
async fn try_sidecar_after_service_unavailable(&self) -> Result<()> {
|
||||||
|
use crate::core::runstate::{RUN_STATE, ServiceHealth};
|
||||||
|
|
||||||
|
let state = RUN_STATE.state();
|
||||||
|
if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let reason = match state.health {
|
||||||
|
ServiceHealth::Unavailable(reason) => reason,
|
||||||
|
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
"Service unavailable ({reason}); Sidecar fallback withheld: {error:#}"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> {
|
||||||
|
use crate::core::runstate::RUN_STATE;
|
||||||
|
|
||||||
|
if Handle::global().is_exiting() {
|
||||||
|
return Err(error.context("application exited before Sidecar fallback"));
|
||||||
|
}
|
||||||
|
let reason = format!("{error:#}");
|
||||||
|
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
|
||||||
|
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
|
||||||
|
let result = self.start_core_by_sidecar().await;
|
||||||
|
if result.is_err() {
|
||||||
|
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||||
|
self.rollback_failed_start().await;
|
||||||
|
} else {
|
||||||
|
crate::core::service::notify_service_fallback(&reason);
|
||||||
|
}
|
||||||
|
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
|
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
|
||||||
pub async fn stop_core(&self) -> Result<()> {
|
pub async fn stop_core(&self) -> Result<()> {
|
||||||
let _life = self.lifecycle_lock.lock().await;
|
let _life = self.lifecycle_lock.lock().await;
|
||||||
@@ -607,10 +718,7 @@ impl CoreManager {
|
|||||||
CLASH_LOGGER.clear_logs();
|
CLASH_LOGGER.clear_logs();
|
||||||
match *self.get_running_mode() {
|
match *self.get_running_mode() {
|
||||||
RunningMode::Service => self.stop_core_by_service().await,
|
RunningMode::Service => self.stop_core_by_service().await,
|
||||||
RunningMode::Sidecar => {
|
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
|
||||||
self.stop_core_by_sidecar_unprepared();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
RunningMode::NotRunning => Ok(()),
|
RunningMode::NotRunning => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1111,9 +1219,10 @@ mod tests {
|
|||||||
|
|
||||||
let result = run_sidecar_termination_transition(
|
let result = run_sidecar_termination_transition(
|
||||||
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|
||||||
|| {
|
|| async {
|
||||||
sidecar_alive.store(false, Ordering::Release);
|
sidecar_alive.store(false, Ordering::Release);
|
||||||
calls.lock().push("handoff-sidecar-stop");
|
calls.lock().push("handoff-sidecar-stop");
|
||||||
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1371,12 +1480,18 @@ mod tests {
|
|||||||
ServiceStatus::NeedsReinstall,
|
ServiceStatus::NeedsReinstall,
|
||||||
ServiceStatus::InstallRequired,
|
ServiceStatus::InstallRequired,
|
||||||
ServiceStatus::Unavailable("offline".into()),
|
ServiceStatus::Unavailable("offline".into()),
|
||||||
|
ServiceStatus::SidecarAllowed,
|
||||||
];
|
];
|
||||||
for status in &allowed_statuses {
|
for status in &allowed_statuses {
|
||||||
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||||
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, 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));
|
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||||
|
} else {
|
||||||
|
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,13 +1501,52 @@ mod tests {
|
|||||||
ServiceStatus::UninstallRequired,
|
ServiceStatus::UninstallRequired,
|
||||||
ServiceStatus::ReinstallRequired,
|
ServiceStatus::ReinstallRequired,
|
||||||
ServiceStatus::ForceReinstallRequired,
|
ServiceStatus::ForceReinstallRequired,
|
||||||
ServiceStatus::SidecarAllowed,
|
|
||||||
];
|
];
|
||||||
for status in &rejected_statuses {
|
for status in &rejected_statuses {
|
||||||
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
|
||||||
|
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
|
||||||
|
|
||||||
|
for idle in [false, true] {
|
||||||
|
let store = RunStateStore::new(FakeEnv::new());
|
||||||
|
store.observe(ServiceHealth::Ready);
|
||||||
|
let started = AtomicBool::new(false);
|
||||||
|
let result = super::run_service_start_with_sidecar_fallback(
|
||||||
|
|| async {
|
||||||
|
Err(crate::core::service::ServiceStartRefusal {
|
||||||
|
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
|
||||||
|
core_path: "verge-mihomo.exe".into(),
|
||||||
|
message: "no administrator-approved copy is installed".into(),
|
||||||
|
}
|
||||||
|
.into())
|
||||||
|
},
|
||||||
|
|| async move {
|
||||||
|
anyhow::ensure!(idle, "another core is running");
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
|_| async {
|
||||||
|
started.store(true, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
|reason| store.observe(ServiceHealth::Unavailable(reason)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(started.load(Ordering::SeqCst), idle);
|
||||||
|
assert_eq!(result.is_ok(), idle);
|
||||||
|
assert!(!store.state().sidecar_allowed);
|
||||||
|
if !idle {
|
||||||
|
assert!(store.state().service_needs_attention());
|
||||||
|
assert!(!store.state().service_usable());
|
||||||
|
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_service_install_can_continue_with_an_existing_sidecar() {
|
fn failed_service_install_can_continue_with_an_existing_sidecar() {
|
||||||
assert!(can_allow_sidecar_for_session(
|
assert!(can_allow_sidecar_for_session(
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug)]
|
||||||
pub struct CoreManager {
|
pub struct CoreManager {
|
||||||
/// The Run State this manager reports transitions to.
|
/// The Run State this manager reports transitions to.
|
||||||
@@ -87,6 +95,8 @@ pub struct CoreManager {
|
|||||||
job_handle: ArcSwapOption<OwnedHandle>,
|
job_handle: ArcSwapOption<OwnedHandle>,
|
||||||
config_update_in_progress: AtomicBool,
|
config_update_in_progress: AtomicBool,
|
||||||
core_readiness_state: AtomicU64,
|
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 交接。
|
// 串行化 start/stop/restart 和 sidecar→service 交接。
|
||||||
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
// 锁序固定为 config_update_in_progress → lifecycle_lock。
|
||||||
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
|
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
|
||||||
@@ -114,6 +124,8 @@ impl Default for CoreManager {
|
|||||||
job_handle: ArcSwapOption::new(None),
|
job_handle: ArcSwapOption::new(None),
|
||||||
config_update_in_progress: AtomicBool::new(false),
|
config_update_in_progress: AtomicBool::new(false),
|
||||||
core_readiness_state: AtomicU64::new(0),
|
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(()),
|
lifecycle_lock: tokio::sync::Mutex::new(()),
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
handoff_watcher_running: AtomicBool::new(false),
|
handoff_watcher_running: AtomicBool::new(false),
|
||||||
@@ -139,6 +151,17 @@ impl CoreManager {
|
|||||||
self.run_state.mode_arc()
|
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> {
|
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
|
||||||
self.state
|
self.state
|
||||||
.load()
|
.load()
|
||||||
@@ -164,7 +187,9 @@ impl CoreManager {
|
|||||||
if previous != mode {
|
if previous != mode {
|
||||||
logging!(info, Type::Core, "Core running mode changed: {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);
|
self.run_state.core_started(mode);
|
||||||
|
*startup_error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Core is no longer running, for any reason.
|
/// The Core is no longer running, for any reason.
|
||||||
@@ -305,3 +330,29 @@ impl CoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
singleton!(CoreManager, CORE_MANAGER);
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,13 +11,67 @@ use crate::{
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use clash_verge_logging::Type;
|
use clash_verge_logging::Type;
|
||||||
use log::Level;
|
use log::Level;
|
||||||
use scopeguard::defer;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri_plugin_mihomo::MihomoExt as _;
|
use tauri_plugin_mihomo::MihomoExt as _;
|
||||||
use tauri_plugin_shell::ShellExt as _;
|
use tauri_plugin_shell::ShellExt as _;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
|
||||||
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
|
|
||||||
|
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
|
||||||
|
let tun = config
|
||||||
|
.entry(Value::from("tun"))
|
||||||
|
.or_insert_with(|| Value::Mapping(Mapping::new()));
|
||||||
|
if !tun.is_mapping() {
|
||||||
|
*tun = Value::Mapping(Mapping::new());
|
||||||
|
}
|
||||||
|
tun.as_mapping_mut()
|
||||||
|
.context("invalid Sidecar TUN configuration")?
|
||||||
|
.insert(Value::from("enable"), Value::Bool(false));
|
||||||
|
Ok(serde_yaml_ng::to_string(&config)?)
|
||||||
|
}
|
||||||
|
|
||||||
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
async fn retry_service_start<Start, StartFuture>(
|
||||||
|
attempts: usize,
|
||||||
|
retry_delay: std::time::Duration,
|
||||||
|
mut start: Start,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
Start: FnMut() -> StartFuture,
|
||||||
|
StartFuture: std::future::Future<Output = Result<()>>,
|
||||||
|
{
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 0..attempts {
|
||||||
|
match start().await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) => {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Core,
|
||||||
|
"service start attempt {}/{} failed: {error:#}",
|
||||||
|
attempt + 1,
|
||||||
|
attempts
|
||||||
|
);
|
||||||
|
if error
|
||||||
|
.downcast_ref::<service::ServiceStartRefusal>()
|
||||||
|
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
|
||||||
|
{
|
||||||
|
return Err(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")))
|
||||||
|
}
|
||||||
|
|
||||||
impl CoreManager {
|
impl CoreManager {
|
||||||
/// Restores profile selections before callers enable the system proxy.
|
/// Restores profile selections before callers enable the system proxy.
|
||||||
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
|
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
|
||||||
@@ -98,12 +152,25 @@ impl CoreManager {
|
|||||||
|
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
|
||||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||||
|
self.wait_for_sidecar_exit().await?;
|
||||||
|
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
|
||||||
|
.await
|
||||||
|
.inspect_err(service::record_residual_service)?;
|
||||||
self.core_stopped();
|
self.core_stopped();
|
||||||
|
|
||||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||||
handle::Handle::app_handle()
|
handle::Handle::app_handle()
|
||||||
.mihomo()
|
.mihomo()
|
||||||
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
|
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let config_file = if crate::core::runstate::RUN_STATE.state().is_admin {
|
||||||
|
Config::generate_file().await?
|
||||||
|
} else {
|
||||||
|
// Reconciliation persists the preference later; the first spawn must already have TUN off.
|
||||||
|
let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?;
|
||||||
|
Config::write_runtime_file(&yaml).await?
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let config_file = Config::generate_file().await?;
|
let config_file = Config::generate_file().await?;
|
||||||
let app_handle = handle::Handle::app_handle();
|
let app_handle = handle::Handle::app_handle();
|
||||||
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
|
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
|
||||||
@@ -141,6 +208,8 @@ impl CoreManager {
|
|||||||
config_dir.display()
|
config_dir.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
let (terminated, termination) = tokio::sync::oneshot::channel();
|
||||||
|
*self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let job = {
|
let job = {
|
||||||
match create_and_assign_sidecar_job(child.pid()) {
|
match create_and_assign_sidecar_job(child.pid()) {
|
||||||
@@ -170,6 +239,40 @@ impl CoreManager {
|
|||||||
let pid = child.pid();
|
let pid = child.pid();
|
||||||
tracing::Span::current().record("pid", pid);
|
tracing::Span::current().record("pid", pid);
|
||||||
|
|
||||||
|
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
|
||||||
|
// single event, so an unread channel stalls its reader threads and the core blocks on a
|
||||||
|
// full stdout pipe before it ever creates the API socket.
|
||||||
|
let core_readiness_generation = self.mark_core_ready();
|
||||||
|
AsyncHandler::spawn(move || async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
||||||
|
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
||||||
|
let message = String::from_utf8_lossy(&line).into_owned();
|
||||||
|
Logger::global().writer_sidecar_log(Level::Error, &message);
|
||||||
|
CLASH_LOGGER.append_log(message);
|
||||||
|
}
|
||||||
|
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
|
||||||
|
let _ = terminated.send(());
|
||||||
|
let manager = Self::global();
|
||||||
|
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
|
||||||
|
let message = if let Some(code) = term.code {
|
||||||
|
format!("Process terminated with code: {}", code)
|
||||||
|
} else if let Some(signal) = term.signal {
|
||||||
|
format!("Process terminated by signal: {}", signal)
|
||||||
|
} else {
|
||||||
|
String::from("Process terminated")
|
||||||
|
};
|
||||||
|
Logger::global().writer_sidecar_log(Level::Info, &message);
|
||||||
|
CLASH_LOGGER.clear_logs();
|
||||||
|
manager.clear_terminated_sidecar(pid).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
|
||||||
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
|
||||||
handle::Handle::mihomo().get_version().await
|
handle::Handle::mihomo().get_version().await
|
||||||
@@ -193,48 +296,15 @@ impl CoreManager {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
self.set_job_handle(Some(job));
|
self.set_job_handle(Some(job));
|
||||||
self.set_running_child_sidecar(child);
|
self.set_running_child_sidecar(child);
|
||||||
let core_readiness_generation = self.mark_core_ready();
|
|
||||||
self.core_started(RunningMode::Sidecar);
|
self.core_started(RunningMode::Sidecar);
|
||||||
self.restore_selected_nodes().await;
|
self.restore_selected_nodes().await;
|
||||||
|
|
||||||
AsyncHandler::spawn(move || async move {
|
|
||||||
while let Some(event) = rx.recv().await {
|
|
||||||
match event {
|
|
||||||
tauri_plugin_shell::process::CommandEvent::Stdout(line)
|
|
||||||
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
|
|
||||||
let message = String::from_utf8_lossy(&line).into_owned();
|
|
||||||
Logger::global().writer_sidecar_log(Level::Error, &message);
|
|
||||||
CLASH_LOGGER.append_log(message);
|
|
||||||
}
|
|
||||||
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
|
|
||||||
let manager = Self::global();
|
|
||||||
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
|
|
||||||
let message = if let Some(code) = term.code {
|
|
||||||
format!("Process terminated with code: {}", code)
|
|
||||||
} else if let Some(signal) = term.signal {
|
|
||||||
format!("Process terminated by signal: {}", signal)
|
|
||||||
} else {
|
|
||||||
String::from("Process terminated")
|
|
||||||
};
|
|
||||||
Logger::global().writer_sidecar_log(Level::Info, &message);
|
|
||||||
CLASH_LOGGER.clear_logs();
|
|
||||||
manager.clear_terminated_sidecar(pid).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminates the sidecar after its caller has successfully cleared the
|
/// Terminates the sidecar after its caller has successfully cleared the
|
||||||
/// system proxy.
|
/// system proxy.
|
||||||
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
|
pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> {
|
||||||
defer! {
|
|
||||||
self.core_stopped();
|
|
||||||
}
|
|
||||||
if let Some(child) = self.take_child_sidecar() {
|
if let Some(child) = self.take_child_sidecar() {
|
||||||
let pid = child.pid();
|
let pid = child.pid();
|
||||||
|
|
||||||
@@ -250,6 +320,22 @@ impl CoreManager {
|
|||||||
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
|
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let result = self.wait_for_sidecar_exit().await;
|
||||||
|
self.core_stopped();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_sidecar_exit(&self) -> Result<()> {
|
||||||
|
let mut exit = self.sidecar_exit.lock().await;
|
||||||
|
if let Some(task) = exit.as_mut() {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), task)
|
||||||
|
.await
|
||||||
|
.context("Sidecar has not exited; core handoff is blocked")?
|
||||||
|
.context("Sidecar exit monitoring failed")?;
|
||||||
|
exit.take();
|
||||||
|
}
|
||||||
|
drop(exit);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||||
@@ -269,30 +355,15 @@ impl CoreManager {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
use crate::constants::timing;
|
use crate::constants::timing;
|
||||||
let mut last_err = None;
|
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
|
||||||
for attempt in 0..timing::SERVICE_START_RETRIES {
|
service::run_core_by_service(config_file)
|
||||||
match service::run_core_by_service(config_file).await {
|
})
|
||||||
Ok(()) => {
|
.await?;
|
||||||
self.mark_core_ready();
|
self.mark_core_ready();
|
||||||
self.core_started(RunningMode::Service);
|
self.core_started(RunningMode::Service);
|
||||||
self.restore_selected_nodes().await;
|
self.restore_selected_nodes().await;
|
||||||
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Core,
|
|
||||||
"service start attempt {}/{} failed: {e:#}",
|
|
||||||
attempt + 1,
|
|
||||||
timing::SERVICE_START_RETRIES
|
|
||||||
);
|
|
||||||
last_err = Some(e);
|
|
||||||
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub(crate) mod owner_identity;
|
|||||||
pub mod proxy_control;
|
pub mod proxy_control;
|
||||||
pub mod proxy_view;
|
pub mod proxy_view;
|
||||||
pub mod runstate;
|
pub mod runstate;
|
||||||
mod runtime_bundle;
|
pub(crate) mod runtime_bundle;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod sysopt;
|
pub mod sysopt;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
|
|||||||
@@ -5,53 +5,35 @@
|
|||||||
|
|
||||||
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
|
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use core_foundation::{
|
use parking_lot::Mutex;
|
||||||
array::CFArray,
|
use sysproxy::NetworkServiceMonitor;
|
||||||
runloop::{CFRunLoop, kCFRunLoopCommonModes},
|
|
||||||
string::CFString,
|
|
||||||
};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext};
|
|
||||||
|
|
||||||
/// Set once the subscription is live. Startup defers the proxy write to the watcher only then.
|
static MONITOR: Mutex<Option<NetworkServiceMonitor>> = Mutex::new(None);
|
||||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
pub fn is_armed() -> bool {
|
pub fn is_armed() -> bool {
|
||||||
ARMED.load(Ordering::Acquire)
|
MONITOR.lock().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service
|
/// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service
|
||||||
/// appearing from here on is never missed.
|
/// appearing from here on is never missed.
|
||||||
pub fn start() {
|
pub fn start() {
|
||||||
let store = SCDynamicStoreBuilder::new("clash-verge-rev")
|
match NetworkServiceMonitor::start(|| {
|
||||||
.callback_context(SCDynamicStoreCallBackContext {
|
AsyncHandler::spawn(reapply);
|
||||||
callout: on_change,
|
}) {
|
||||||
info: (),
|
Ok(monitor) => {
|
||||||
})
|
*MONITOR.lock() = Some(monitor);
|
||||||
.build();
|
logging!(
|
||||||
let keys = CFArray::from_CFTypes(&[CFString::from_static_string("State:/Network/Global/IPv4")]);
|
debug,
|
||||||
let Some(source) = store
|
Type::Core,
|
||||||
.filter(|store| store.set_notification_keys(&keys, &CFArray::<CFString>::from_CFTypes(&[])))
|
"network watch armed; the system proxy is re-applied on network changes"
|
||||||
.and_then(|store| store.create_run_loop_source())
|
);
|
||||||
else {
|
}
|
||||||
logging!(
|
Err(error) => logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Core,
|
Type::Core,
|
||||||
"could not watch the network; the proxy is not re-applied on changes"
|
"could not watch the network; the proxy is not re-applied on changes: {error}"
|
||||||
);
|
),
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
CFRunLoop::get_main().add_source(&source, unsafe { kCFRunLoopCommonModes });
|
|
||||||
ARMED.store(true, Ordering::Release);
|
|
||||||
logging!(
|
|
||||||
debug,
|
|
||||||
Type::Core,
|
|
||||||
"network watch armed; the system proxy is re-applied on network changes"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_change(_: SCDynamicStore, _: CFArray<CFString>, (): &mut ()) {
|
|
||||||
AsyncHandler::spawn(reapply);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reapply() {
|
async fn reapply() {
|
||||||
@@ -66,10 +48,17 @@ async fn reapply() {
|
|||||||
.latest_arc()
|
.latest_arc()
|
||||||
.enable_system_proxy
|
.enable_system_proxy
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|| !proxy_control::has_network_service().await
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !proxy_control::has_network_service().await {
|
||||||
|
logging!(
|
||||||
|
debug,
|
||||||
|
Type::Core,
|
||||||
|
"network changed without a service to write the proxy on; waiting for the next change"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Err(error) = manager.apply_proxy_after_start().await {
|
if let Err(error) = manager.apply_proxy_after_start().await {
|
||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
|
|||||||
@@ -392,13 +392,10 @@ mod windows_owner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
|
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
|
||||||
|
// Elevated Windows processes can create directories owned by Administrators.
|
||||||
|
std::fs::create_dir_all(root).context("failed to create the application data root before owner repair")?;
|
||||||
// The Service canonicalizes before checking, so resolve junctions to the same object.
|
// The Service canonicalizes before checking, so resolve junctions to the same object.
|
||||||
let root = match std::fs::canonicalize(root) {
|
let root = std::fs::canonicalize(root).context("failed to canonicalize the application data root")?;
|
||||||
Ok(path) => path,
|
|
||||||
// Nothing to repair: this process creates the root and owns it by construction.
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
||||||
Err(error) => return Err(error).context("failed to canonicalize the application data root"),
|
|
||||||
};
|
|
||||||
let root = root.as_path();
|
let root = root.as_path();
|
||||||
|
|
||||||
let sid = current_sid()?;
|
let sid = current_sid()?;
|
||||||
|
|||||||
@@ -385,12 +385,11 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether macOS has a network service to write the proxy on right now.
|
/// Whether macOS has a network service to write the proxy on right now.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
pub async fn has_network_service() -> bool {
|
pub async fn has_network_service() -> bool {
|
||||||
tokio::task::spawn_blocking(
|
tokio::task::spawn_blocking(|| sysproxy::Sysproxy::has_network_service().unwrap_or(true))
|
||||||
|| !matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)),
|
.await
|
||||||
)
|
.unwrap_or(true)
|
||||||
.await
|
|
||||||
.unwrap_or(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
pub fn is_reportable(error: &anyhow::Error) -> bool {
|
||||||
|
|||||||
@@ -27,8 +27,42 @@ pub struct RealEnv;
|
|||||||
|
|
||||||
impl RunStateEnv for RealEnv {
|
impl RunStateEnv for RealEnv {
|
||||||
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
|
||||||
|
// The IPC client would otherwise back off for about two minutes before failing.
|
||||||
|
#[cfg(windows)]
|
||||||
|
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
|
||||||
|
.await
|
||||||
|
.context("service status probe did not finish")??
|
||||||
|
{
|
||||||
|
anyhow::bail!("the Windows service is not running");
|
||||||
|
}
|
||||||
let response = clash_verge_service_ipc::get_version().await?;
|
let response = clash_verge_service_ipc::get_version().await?;
|
||||||
|
let core = if response.code == 0
|
||||||
|
&& response.data.as_ref().is_some_and(|info| {
|
||||||
|
info.supports_client(
|
||||||
|
clash_verge_service_ipc::ProtocolVersion::current(),
|
||||||
|
clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION,
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
let name = format!(
|
||||||
|
"{}{}",
|
||||||
|
crate::config::Config::verge().await.latest_arc().get_valid_clash_core(),
|
||||||
|
std::env::consts::EXE_SUFFIX
|
||||||
|
);
|
||||||
|
let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement {
|
||||||
|
name: name.clone(),
|
||||||
|
sha256: None,
|
||||||
|
}])
|
||||||
|
.await?;
|
||||||
|
status
|
||||||
|
.cores
|
||||||
|
.into_iter()
|
||||||
|
.find(|core| core.name == name)
|
||||||
|
.map(|core| core.availability)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
Ok(ServiceVersionReply {
|
Ok(ServiceVersionReply {
|
||||||
|
core,
|
||||||
code: response.code,
|
code: response.code,
|
||||||
message: response.message,
|
message: response.message,
|
||||||
protocol: response.data,
|
protocol: response.data,
|
||||||
@@ -36,9 +70,17 @@ impl RunStateEnv for RealEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn trusted_install_evidence(&self) -> Result<bool> {
|
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
|
.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 {
|
fn is_elevated(&self) -> bool {
|
||||||
@@ -119,6 +161,7 @@ mod fake {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn service_ready(self) -> Self {
|
pub fn service_ready(self) -> Self {
|
||||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: Some(ProtocolInfo::current()),
|
protocol: Some(ProtocolInfo::current()),
|
||||||
@@ -128,6 +171,7 @@ mod fake {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn service_version_mismatch(self) -> Self {
|
pub fn service_version_mismatch(self) -> Self {
|
||||||
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: None,
|
protocol: None,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub use env::FakeEnv;
|
|||||||
pub use env::{RealEnv, RunStateEnv};
|
pub use env::{RealEnv, RunStateEnv};
|
||||||
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
|
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
|
||||||
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
|
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 crate::core::manager::RunningMode;
|
||||||
use health::StoredService;
|
use health::StoredService;
|
||||||
@@ -152,6 +152,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
|||||||
self.observe(ServiceHealth::Ready);
|
self.observe(ServiceHealth::Ready);
|
||||||
Ok(self.state())
|
Ok(self.state())
|
||||||
}
|
}
|
||||||
|
ServiceVersionCheck::CoreUnavailable(error) => {
|
||||||
|
self.observe(ServiceHealth::Unavailable(error.clone()));
|
||||||
|
bail!(error)
|
||||||
|
}
|
||||||
ServiceVersionCheck::NeedsReinstall(error) => {
|
ServiceVersionCheck::NeedsReinstall(error) => {
|
||||||
self.observe(ServiceHealth::VersionMismatch);
|
self.observe(ServiceHealth::VersionMismatch);
|
||||||
bail!(error)
|
bail!(error)
|
||||||
@@ -178,7 +182,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match self.env.probe_service_version().await {
|
match self.env.probe_service_version().await {
|
||||||
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
|
Ok(reply) => match classify_service_version_reply(&reply) {
|
||||||
|
ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason),
|
||||||
|
_ => classify_service_health(probe_outcome(&reply), has_marker, ""),
|
||||||
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
|
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
|
||||||
classify_service_health(
|
classify_service_health(
|
||||||
@@ -323,6 +330,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
|
||||||
|
let mut state = self.service.lock();
|
||||||
|
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
|
||||||
|
bail!("cannot fall back while a service operation is pending");
|
||||||
|
}
|
||||||
|
// Publish the refusal and allowance together so it never asks the UI for a repair.
|
||||||
|
state.service.health = ServiceHealth::Unavailable(reason);
|
||||||
|
state.service.allow_sidecar();
|
||||||
|
state.bump();
|
||||||
|
drop(state);
|
||||||
|
self.announce();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Requests installation only for a confirmed absent Service.
|
/// Requests installation only for a confirmed absent Service.
|
||||||
pub fn require_install_for_session(&self) -> Result<()> {
|
pub fn require_install_for_session(&self) -> Result<()> {
|
||||||
let mut state = self.service.lock();
|
let mut state = self.service.lock();
|
||||||
@@ -472,6 +494,7 @@ mod tests {
|
|||||||
|
|
||||||
fn ready_reply() -> ServiceVersionReply {
|
fn ready_reply() -> ServiceVersionReply {
|
||||||
ServiceVersionReply {
|
ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
|
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
|
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
|
||||||
//! systemd, SCM or launchd.
|
//! systemd, SCM or launchd.
|
||||||
|
|
||||||
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
|
use clash_verge_service_ipc::{
|
||||||
|
MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion, VERSION as SERVICE_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
use super::health::ServiceHealth;
|
use super::health::ServiceHealth;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ pub struct ServiceVersionReply {
|
|||||||
pub code: u16,
|
pub code: u16,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub protocol: Option<ProtocolInfo>,
|
pub protocol: Option<ProtocolInfo>,
|
||||||
|
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The verdict on a [`ServiceVersionReply`].
|
/// The verdict on a [`ServiceVersionReply`].
|
||||||
@@ -21,8 +24,12 @@ pub struct ServiceVersionReply {
|
|||||||
pub enum ServiceVersionCheck {
|
pub enum ServiceVersionCheck {
|
||||||
Ready,
|
Ready,
|
||||||
NeedsReinstall(String),
|
NeedsReinstall(String),
|
||||||
|
CoreUnavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Leads the Unavailable reason when the Service refused the approved core itself.
|
||||||
|
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
|
||||||
|
|
||||||
/// What a live probe of the currently-installed Service told us.
|
/// What a live probe of the currently-installed Service told us.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CurrentServiceProbe {
|
pub enum CurrentServiceProbe {
|
||||||
@@ -32,22 +39,30 @@ pub enum CurrentServiceProbe {
|
|||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether the Service we reached speaks a protocol this client can use.
|
/// Require the bundled Service version as well as a usable protocol.
|
||||||
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
|
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
|
||||||
let client = ProtocolVersion::current();
|
let client = ProtocolVersion::current();
|
||||||
if reply.code == 0
|
if reply.code == 0
|
||||||
&& reply
|
&& reply.protocol.as_ref().is_some_and(|info| {
|
||||||
.protocol
|
info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)
|
||||||
.as_ref()
|
})
|
||||||
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
|
|
||||||
{
|
{
|
||||||
return ServiceVersionCheck::Ready;
|
return match &reply.core {
|
||||||
|
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
|
||||||
|
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
|
||||||
|
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
|
||||||
|
}
|
||||||
|
_ => ServiceVersionCheck::CoreUnavailable(
|
||||||
|
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let detail = if reply.code == 0 {
|
let detail = if reply.code == 0 {
|
||||||
match reply.protocol.as_ref() {
|
match reply.protocol.as_ref() {
|
||||||
Some(info) => format!(
|
Some(info) => format!(
|
||||||
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
|
"client requires service version {} with epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
|
||||||
|
SERVICE_VERSION,
|
||||||
client.epoch,
|
client.epoch,
|
||||||
MIN_REQUIRED_SERVICE_REVISION,
|
MIN_REQUIRED_SERVICE_REVISION,
|
||||||
info.protocol.epoch,
|
info.protocol.epoch,
|
||||||
@@ -63,16 +78,16 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
ServiceVersionCheck::NeedsReinstall(format!(
|
ServiceVersionCheck::NeedsReinstall(format!(
|
||||||
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
|
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
|
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
|
||||||
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
|
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
|
||||||
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
|
match classify_service_version_reply(reply) {
|
||||||
CurrentServiceProbe::Ready
|
ServiceVersionCheck::Ready => CurrentServiceProbe::Ready,
|
||||||
} else {
|
ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch,
|
||||||
CurrentServiceProbe::VersionMismatch
|
ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +117,7 @@ mod tests {
|
|||||||
|
|
||||||
fn ready_reply() -> ServiceVersionReply {
|
fn ready_reply() -> ServiceVersionReply {
|
||||||
ServiceVersionReply {
|
ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: Some(ProtocolInfo::current()),
|
protocol: Some(ProtocolInfo::current()),
|
||||||
@@ -117,9 +133,26 @@ mod tests {
|
|||||||
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
|
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatible_service_with_missing_core_is_unavailable() {
|
||||||
|
for core in [
|
||||||
|
None,
|
||||||
|
Some(clash_verge_service_ipc::CoreAvailability::Missing),
|
||||||
|
Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch),
|
||||||
|
] {
|
||||||
|
let reply = ServiceVersionReply { core, ..ready_reply() };
|
||||||
|
assert!(matches!(
|
||||||
|
classify_service_version_reply(&reply),
|
||||||
|
ServiceVersionCheck::CoreUnavailable(_)
|
||||||
|
));
|
||||||
|
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_reply_without_protocol_information_needs_reinstall() {
|
fn a_reply_without_protocol_information_needs_reinstall() {
|
||||||
let reply = ServiceVersionReply {
|
let reply = ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: None,
|
protocol: None,
|
||||||
@@ -137,6 +170,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_nonzero_code_needs_reinstall() {
|
fn a_nonzero_code_needs_reinstall() {
|
||||||
let reply = ServiceVersionReply {
|
let reply = ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 7,
|
code: 7,
|
||||||
message: "nope".to_owned(),
|
message: "nope".to_owned(),
|
||||||
protocol: Some(ProtocolInfo::current()),
|
protocol: Some(ProtocolInfo::current()),
|
||||||
@@ -152,6 +186,7 @@ mod tests {
|
|||||||
let mut info = ProtocolInfo::current();
|
let mut info = ProtocolInfo::current();
|
||||||
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
|
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
|
||||||
let reply = ServiceVersionReply {
|
let reply = ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: Some(info),
|
protocol: Some(info),
|
||||||
@@ -172,6 +207,7 @@ mod tests {
|
|||||||
let mut info = ProtocolInfo::current();
|
let mut info = ProtocolInfo::current();
|
||||||
info.protocol.revision = revision;
|
info.protocol.revision = revision;
|
||||||
let reply = ServiceVersionReply {
|
let reply = ServiceVersionReply {
|
||||||
|
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
|
||||||
code: 0,
|
code: 0,
|
||||||
message: "ok".to_owned(),
|
message: "ok".to_owned(),
|
||||||
protocol: Some(info),
|
protocol: Some(info),
|
||||||
@@ -190,6 +226,7 @@ mod tests {
|
|||||||
for protocol in [None, Some(old_epoch)] {
|
for protocol in [None, Some(old_epoch)] {
|
||||||
for code in [0, 1] {
|
for code in [0, 1] {
|
||||||
let reply = ServiceVersionReply {
|
let reply = ServiceVersionReply {
|
||||||
|
core: None,
|
||||||
code,
|
code,
|
||||||
message: "unsupported command".to_owned(),
|
message: "unsupported command".to_owned(),
|
||||||
protocol: protocol.clone(),
|
protocol: protocol.clone(),
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ use anyhow::{Context as _, Result, bail};
|
|||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use std::collections::HashSet;
|
use sha2::{Digest as _, Sha256};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
pub(crate) const GEO_ASSETS: &[&str] = &[
|
pub(crate) const GEO_ASSETS: &[&str] = &[
|
||||||
"Country.mmdb",
|
"Country.mmdb",
|
||||||
|
"ASN.mmdb",
|
||||||
"geoip.dat",
|
"geoip.dat",
|
||||||
"geosite.dat",
|
"geosite.dat",
|
||||||
"geoip.metadb",
|
"geoip.metadb",
|
||||||
@@ -53,6 +55,94 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul
|
|||||||
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
|
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProviderPathOwners {
|
||||||
|
remote: BTreeMap<String, Vec<(&'static str, Value)>>,
|
||||||
|
local: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
|
||||||
|
let config_root = std::fs::canonicalize(config_root)?;
|
||||||
|
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
|
||||||
|
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
|
||||||
|
for section in ["proxy-providers", "rule-providers"] {
|
||||||
|
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for (name, provider) in providers {
|
||||||
|
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
|
||||||
|
let destination = if is_remote {
|
||||||
|
provider_destination(&config_root, raw_path)
|
||||||
|
} else {
|
||||||
|
local_provider_source(&config_root, raw_path)
|
||||||
|
.and_then(|source| destination_below_root(&config_root, &source))
|
||||||
|
.or_else(|_| provider_destination(&config_root, raw_path))
|
||||||
|
};
|
||||||
|
let Ok(destination) = destination else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
reserved.insert(destination.clone());
|
||||||
|
let owners = paths.entry(destination).or_default();
|
||||||
|
match (is_remote, provider.get("url").and_then(Value::as_str)) {
|
||||||
|
(true, Some(url)) => owners
|
||||||
|
.remote
|
||||||
|
.entry(url.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.push((section, name.clone())),
|
||||||
|
_ => owners.local = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (destination, owners) in paths {
|
||||||
|
if owners.local || owners.remote.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Allocate every source in a conflict group, so declaration order cannot select a cache's owner.
|
||||||
|
for (url, providers) in owners.remote {
|
||||||
|
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
|
||||||
|
for (section, name) in providers {
|
||||||
|
config[section][&name]["path"] = Value::String(replacement.clone());
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Config,
|
||||||
|
"provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet<String>) -> String {
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
digest.update(destination.as_bytes());
|
||||||
|
digest.update([0]);
|
||||||
|
digest.update(url.as_bytes());
|
||||||
|
let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
let path = Path::new(destination);
|
||||||
|
let extension = path
|
||||||
|
.extension()
|
||||||
|
.map(|ext| format!(".{}", ext.to_string_lossy()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut suffix = String::new();
|
||||||
|
let mut sequence = 0;
|
||||||
|
loop {
|
||||||
|
let candidate = path
|
||||||
|
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
if reserved.insert(candidate.clone()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
sequence += 1;
|
||||||
|
suffix = format!("-{sequence}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
|
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
|
||||||
let config_root = std::fs::canonicalize(config_root)?;
|
let config_root = std::fs::canonicalize(config_root)?;
|
||||||
let mut assets = Vec::new();
|
let mut assets = Vec::new();
|
||||||
@@ -254,7 +344,91 @@ fn normalized_destination(relative: &Path) -> Result<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::collect_runtime_bundle;
|
use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> {
|
||||||
|
let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root)?;
|
||||||
|
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||||
|
"rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
|
||||||
|
)?;
|
||||||
|
let original = config.clone();
|
||||||
|
let root = std::fs::canonicalize(root)?;
|
||||||
|
let mut reordered = config.clone();
|
||||||
|
reordered["rule-providers"] = serde_yaml_ng::from_str(&format!(
|
||||||
|
"mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
|
||||||
|
root.join("providers/rule/AppleDev.yaml").display(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||||
|
let config_file = root.join("config.yaml");
|
||||||
|
std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?;
|
||||||
|
let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?;
|
||||||
|
let paths = &config["rule-providers"];
|
||||||
|
assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]);
|
||||||
|
resolve_provider_path_conflicts(&mut reordered, &root)?;
|
||||||
|
assert_eq!(
|
||||||
|
config, reordered,
|
||||||
|
"path allocation must not depend on declaration order or path spelling"
|
||||||
|
);
|
||||||
|
let resolved = config.clone();
|
||||||
|
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||||
|
assert_eq!(
|
||||||
|
config, resolved,
|
||||||
|
"resolving an already generated config must be idempotent"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&bundle.yaml)?, config);
|
||||||
|
let readback = remote_providers_of(&config, &root)?;
|
||||||
|
assert_eq!(bundle.remote_providers.len(), 2);
|
||||||
|
for declared in readback {
|
||||||
|
assert!(bundle.remote_providers.contains(&declared.provider));
|
||||||
|
assert_eq!(
|
||||||
|
config[declared.section][&declared.name]["path"],
|
||||||
|
declared.provider.destination
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config[declared.section][&declared.name]["url"],
|
||||||
|
original[declared.section][&declared.name]["url"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(root)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> {
|
||||||
|
let root = std::env::temp_dir();
|
||||||
|
let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||||
|
"proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n",
|
||||||
|
)?;
|
||||||
|
let mut config = original.clone();
|
||||||
|
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||||
|
let allocated = config["proxy-providers"]["a"]["path"].clone();
|
||||||
|
let mut config = original;
|
||||||
|
config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?;
|
||||||
|
config["rule-providers"]["reserved"]["path"] = allocated.clone();
|
||||||
|
resolve_provider_path_conflicts(&mut config, &root)?;
|
||||||
|
assert_ne!(config["proxy-providers"]["a"]["path"], allocated);
|
||||||
|
assert_ne!(
|
||||||
|
config["proxy-providers"]["a"]["path"],
|
||||||
|
config["rule-providers"]["b"]["path"]
|
||||||
|
);
|
||||||
|
assert_eq!(config["rule-providers"]["reserved"]["path"], allocated);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> {
|
||||||
|
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
|
||||||
|
"rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n",
|
||||||
|
)?;
|
||||||
|
let original = config.clone();
|
||||||
|
resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?;
|
||||||
|
assert_eq!(config, original);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
|
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
|
||||||
|
|||||||
+312
-386
@@ -5,12 +5,12 @@ use crate::{
|
|||||||
core::{
|
core::{
|
||||||
CoreManager,
|
CoreManager,
|
||||||
handle::Handle,
|
handle::Handle,
|
||||||
manager::RunningMode,
|
manager::{CoreFailure, RunningMode},
|
||||||
owner_identity::current_owner_credentials,
|
owner_identity::current_owner_credentials,
|
||||||
proxy_control,
|
proxy_control,
|
||||||
runstate::{
|
runstate::{
|
||||||
OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError,
|
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
|
||||||
RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
|
||||||
},
|
},
|
||||||
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
|
||||||
tray::Tray,
|
tray::Tray,
|
||||||
@@ -21,13 +21,13 @@ use anyhow::{Context as _, Result, anyhow, bail};
|
|||||||
use clash_verge_draft::Draft;
|
use clash_verge_draft::Draft;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use clash_verge_service_ipc::{
|
use clash_verge_service_ipc::{
|
||||||
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
|
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
|
||||||
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
|
||||||
|
StageRuntimeOutcome, StartClashRequest, WriterConfig,
|
||||||
};
|
};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env::current_exe,
|
env::current_exe,
|
||||||
future::Future,
|
future::Future,
|
||||||
@@ -39,6 +39,49 @@ use std::{
|
|||||||
|
|
||||||
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||||
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
|
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| 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);
|
||||||
|
|
||||||
|
/// Why the Service was unavailable when the core fell back to Sidecar.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
|
||||||
|
pub enum ServiceFallbackNotice {
|
||||||
|
Unavailable,
|
||||||
|
CoreRejected(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn notify_service_fallback(reason: &str) {
|
||||||
|
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
|
||||||
|
Handle::notice_message("service_core::sidecar_fallback", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
|
||||||
|
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
|
||||||
|
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
|
||||||
|
ServiceFallbackNotice::CoreRejected(reason)
|
||||||
|
} else {
|
||||||
|
ServiceFallbackNotice::Unavailable
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_service_repair_notice() -> bool {
|
||||||
|
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the command that gives the app data root back to this account.
|
||||||
|
pub(crate) fn take_service_owner_notice() -> Option<String> {
|
||||||
|
PENDING_SERVICE_OWNER_NOTICE.lock().take()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
|
||||||
|
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let path = credentials.app_data_dir.replace('\'', r"'\''");
|
||||||
|
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Capabilities of the service session that owns the running Core.
|
/// Capabilities of the service session that owns the running Core.
|
||||||
/// They are discarded with that session rather than cached across service upgrades.
|
/// They are discarded with that session rather than cached across service upgrades.
|
||||||
@@ -212,7 +255,7 @@ fn macos_service_install_marker_exists() -> std::io::Result<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
|
||||||
use windows_service::{
|
use windows_service::{
|
||||||
Error as WindowsServiceError,
|
Error as WindowsServiceError,
|
||||||
service::ServiceAccess,
|
service::ServiceAccess,
|
||||||
@@ -225,17 +268,36 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
|||||||
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
|
||||||
ServiceAccess::QUERY_STATUS,
|
ServiceAccess::QUERY_STATUS,
|
||||||
) {
|
) {
|
||||||
Ok(service) => {
|
Ok(service) => Ok(Some(service)),
|
||||||
drop(service);
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
|
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"),
|
Err(error) => Err(error).context("failed to inspect Windows service registration"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||||
|
Ok(open_registered_service()?.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether IPC cannot succeed until the service is started again. A service that is starting, or
|
||||||
|
/// that the SCM has not started yet this boot, is left to the IPC retries, which wait for it.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) fn service_stopped() -> Result<bool> {
|
||||||
|
use windows_service::service::{ServiceExitCode, ServiceState};
|
||||||
|
|
||||||
|
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
|
||||||
|
let Some(service) = open_registered_service()? else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
let status = service
|
||||||
|
.query_status()
|
||||||
|
.context("failed to query Windows service status")?;
|
||||||
|
Ok(status.current_state == ServiceState::Stopped
|
||||||
|
&& status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
pub(crate) fn trusted_service_evidence() -> Result<bool> {
|
||||||
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
|
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
|
||||||
@@ -471,44 +533,16 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
|
|||||||
Ok(sibling)
|
Ok(sibling)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 卸载服务前以 root 清理残留 core 和 IPC 套接字。
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn macos_force_stop_core_shell() -> String {
|
|
||||||
use crate::config::IVerge;
|
|
||||||
|
|
||||||
// 只清理 root 拥有的服务内核。
|
|
||||||
let mut parts: Vec<String> = IVerge::VALID_CLASH_CORES
|
|
||||||
.iter()
|
|
||||||
.map(|core| format!("/usr/bin/pkill -U root -x {core} 2>/dev/null || true"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if let Ok(ipc) = dirs::ipc_path()
|
|
||||||
&& let Ok(ipc_str) = dirs::path_to_str(&ipc)
|
|
||||||
{
|
|
||||||
// 转义单引号,避免破坏 shell 参数。
|
|
||||||
let escaped = ipc_str.replace('\'', r"'\''");
|
|
||||||
parts.push(format!("/bin/rm -f '{escaped}' 2>/dev/null || true"));
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.join("; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
fn escape_osascript_double_quoted_string(value: &str) -> String {
|
||||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", test))]
|
#[cfg(target_os = "macos")]
|
||||||
fn shell_single_quote(value: &str) -> String {
|
fn shell_single_quote(value: &str) -> String {
|
||||||
format!("'{}'", value.replace('\'', r"'\''"))
|
format!("'{}'", value.replace('\'', r"'\''"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", test))]
|
|
||||||
fn macos_install_shell(install_path: &Path, gid: u32) -> String {
|
|
||||||
let install_quoted = shell_single_quote(&install_path.to_string_lossy());
|
|
||||||
format!("cd /; CLASH_VERGE_SERVICE_GID={gid} {install_quoted}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
|
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
|
||||||
#[cfg(feature = "verge-dev")]
|
#[cfg(feature = "verge-dev")]
|
||||||
{
|
{
|
||||||
@@ -562,54 +596,6 @@ fn uninstall_service() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn install_service() -> Result<()> {
|
|
||||||
use std::process::Output;
|
|
||||||
logging!(info, Type::Service, "install service");
|
|
||||||
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
|
||||||
use runas::Command as RunasCommand;
|
|
||||||
use std::os::windows::process::CommandExt as _;
|
|
||||||
|
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !install_path.exists() {
|
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = Token::with_current_process()?;
|
|
||||||
let level = token.privilege_level()?;
|
|
||||||
let output = match level {
|
|
||||||
PrivilegeLevel::NotPrivileged => {
|
|
||||||
let status = RunasCommand::new(&install_path).show(false).status()?;
|
|
||||||
Output {
|
|
||||||
status,
|
|
||||||
stdout: Vec::new(),
|
|
||||||
stderr: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// StdCommand returns Output directly
|
|
||||||
StdCommand::new(&install_path).creation_flags(0x08000000).output()?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
logging!(
|
|
||||||
error,
|
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn uninstall_service() -> Result<()> {
|
fn uninstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "uninstall service");
|
logging!(info, Type::Service, "uninstall service");
|
||||||
@@ -661,55 +647,6 @@ fn uninstall_service() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn install_service() -> Result<()> {
|
|
||||||
logging!(info, Type::Service, "install service");
|
|
||||||
|
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
||||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !install_path.exists() {
|
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let elevator = crate::utils::help::linux_elevator();
|
|
||||||
let output = if linux_running_as_root() {
|
|
||||||
StdCommand::new(&install_path).output()?
|
|
||||||
} else {
|
|
||||||
let result = StdCommand::new(&elevator)
|
|
||||||
.arg("--disable-internal-agent")
|
|
||||||
.arg(&install_path)
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
// 如果 pkexec 执行失败,回退到 sudo
|
|
||||||
if !result.status.success() && elevator.contains("pkexec") {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Service,
|
|
||||||
"pkexec failed with code {}, falling back to sudo",
|
|
||||||
result.status.code().unwrap_or(-1)
|
|
||||||
);
|
|
||||||
StdCommand::new("sudo").arg(&install_path).output()?
|
|
||||||
} else {
|
|
||||||
result
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
logging!(
|
|
||||||
error,
|
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn linux_running_as_root() -> bool {
|
fn linux_running_as_root() -> bool {
|
||||||
use crate::core::handle;
|
use crate::core::handle;
|
||||||
@@ -736,9 +673,8 @@ fn uninstall_service() -> Result<()> {
|
|||||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
||||||
|
|
||||||
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
|
let prompt = clash_verge_i18n::t!("service.adminUninstallPrompt");
|
||||||
// 先清理服务残留,再执行卸载器。
|
|
||||||
let uninstall_quoted = shell_single_quote(&uninstall_shell);
|
let uninstall_quoted = shell_single_quote(&uninstall_shell);
|
||||||
let shell = format!("cd /; {}; {uninstall_quoted}", macos_force_stop_core_shell());
|
let shell = format!("cd /; {uninstall_quoted}");
|
||||||
let shell = escape_osascript_double_quoted_string(&shell);
|
let shell = escape_osascript_double_quoted_string(&shell);
|
||||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
||||||
|
|
||||||
@@ -756,63 +692,46 @@ fn uninstall_service() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn install_service() -> Result<()> {
|
fn install_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "install service");
|
let executable = current_exe()?;
|
||||||
|
let cores = crate::config::IVerge::VALID_CLASH_CORES
|
||||||
let binary_path = packaged_service_tool_path("clash-verge-service", dirs::service_path)?;
|
.iter()
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
.map(|core| {
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
let name = format!("{core}{}", std::env::consts::EXE_SUFFIX);
|
||||||
})?;
|
clash_verge_service_ipc::management::CoreSource {
|
||||||
|
path: executable.with_file_name(&name),
|
||||||
if !install_path.exists() {
|
name,
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
}
|
||||||
}
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
macos_service_tool_path(&binary_path)?;
|
invoke_service_install(&cores, false)
|
||||||
let install_path = macos_service_tool_path(&install_path)?;
|
|
||||||
|
|
||||||
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
|
|
||||||
|
|
||||||
let gid = tauri_plugin_clash_verge_sysinfo::current_gid();
|
|
||||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
|
||||||
let shell = macos_install_shell(&install_path, gid);
|
|
||||||
let shell = escape_osascript_double_quoted_string(&shell);
|
|
||||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
|
||||||
|
|
||||||
let output = StdCommand::new("osascript").args(vec!["-e", &command]).output()?;
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
logging!(
|
|
||||||
error,
|
|
||||||
Type::Service,
|
|
||||||
"failed to install service code: {}, details: {}",
|
|
||||||
code,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
bail!("failed to install service code: {}, details: {}", code, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_output_error(output: &std::process::Output) -> Option<(i32, Cow<'_, str>)> {
|
fn invoke_service_install(cores: &[clash_verge_service_ipc::management::CoreSource], core_only: bool) -> Result<()> {
|
||||||
if output.status.success() {
|
let name = format!("clash-verge-service-install{}", std::env::consts::EXE_SUFFIX);
|
||||||
return None;
|
let installer = packaged_service_tool_path(&name, || {
|
||||||
}
|
#[cfg(target_os = "linux")]
|
||||||
let code = output.status.code().unwrap_or(-1);
|
let executable = tauri::utils::platform::current_exe()?;
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
#[cfg(not(target_os = "linux"))]
|
||||||
if !stderr.is_empty() {
|
let executable = dirs::service_path()?;
|
||||||
return Some((code, stderr));
|
Ok(executable.with_file_name(&name))
|
||||||
}
|
})?;
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
#[cfg(unix)]
|
||||||
if !stdout.is_empty() {
|
let gid = Some(tauri_plugin_clash_verge_sysinfo::current_gid());
|
||||||
return Some((code, stdout));
|
#[cfg(windows)]
|
||||||
}
|
let gid = None;
|
||||||
Some((code, Cow::Borrowed("Unknown error")))
|
clash_verge_service_ipc::management::install(
|
||||||
|
&installer,
|
||||||
|
cores,
|
||||||
|
core_only,
|
||||||
|
gid,
|
||||||
|
&clash_verge_i18n::t!("service.adminInstallPrompt"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reinstall_service() -> Result<()> {
|
fn reinstall_service() -> Result<()> {
|
||||||
logging!(info, Type::Service, "reinstall service");
|
logging!(info, Type::Service, "reinstall service");
|
||||||
|
// The installer replaces an existing registration and its cores in one elevation.
|
||||||
install_service()
|
install_service()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,158 +753,20 @@ fn force_reinstall_service() -> Result<()> {
|
|||||||
/// hash was computed is refused by the installer instead of published.
|
/// hash was computed is refused by the installer instead of published.
|
||||||
pub fn stage_approved_core(core_path: &Path) -> Result<()> {
|
pub fn stage_approved_core(core_path: &Path) -> Result<()> {
|
||||||
tokio::task::block_in_place(|| {
|
tokio::task::block_in_place(|| {
|
||||||
let digest = sha256_hex(core_path)?;
|
invoke_service_install(
|
||||||
run_core_install(core_path, &digest)
|
&[clash_verge_service_ipc::management::CoreSource {
|
||||||
|
name: core_path
|
||||||
|
.file_name()
|
||||||
|
.context("core has no filename")?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
path: core_path.to_path_buf(),
|
||||||
|
}],
|
||||||
|
true,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sha256_hex(path: &Path) -> Result<String> {
|
|
||||||
use sha2::{Digest as _, Sha256};
|
|
||||||
use std::io::Read as _;
|
|
||||||
|
|
||||||
let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
|
||||||
loop {
|
|
||||||
let read = file
|
|
||||||
.read(&mut buffer)
|
|
||||||
.with_context(|| format!("failed to read {path:?}"))?;
|
|
||||||
if read == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buffer[..read]);
|
|
||||||
}
|
|
||||||
Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
||||||
use deelevate::{PrivilegeLevel, Token};
|
|
||||||
use runas::Command as RunasCommand;
|
|
||||||
use std::os::windows::process::CommandExt as _;
|
|
||||||
use std::process::Output;
|
|
||||||
|
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || {
|
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe"))
|
|
||||||
})?;
|
|
||||||
if !install_path.exists() {
|
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = Token::with_current_process()?;
|
|
||||||
let output = match token.privilege_level()? {
|
|
||||||
PrivilegeLevel::NotPrivileged => {
|
|
||||||
let status = RunasCommand::new(&install_path)
|
|
||||||
.arg("--install-core")
|
|
||||||
.arg(core_path)
|
|
||||||
.arg("--sha256")
|
|
||||||
.arg(sha256_hex)
|
|
||||||
.show(false)
|
|
||||||
.status()?;
|
|
||||||
Output {
|
|
||||||
status,
|
|
||||||
stdout: Vec::new(),
|
|
||||||
stderr: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => StdCommand::new(&install_path)
|
|
||||||
.creation_flags(0x08000000)
|
|
||||||
.arg("--install-core")
|
|
||||||
.arg(core_path)
|
|
||||||
.arg("--sha256")
|
|
||||||
.arg(sha256_hex)
|
|
||||||
.output()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
||||||
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install"))
|
|
||||||
})?;
|
|
||||||
if !install_path.exists() {
|
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let core_argument = core_path.as_os_str();
|
|
||||||
let output = if linux_running_as_root() {
|
|
||||||
StdCommand::new(&install_path)
|
|
||||||
.arg("--install-core")
|
|
||||||
.arg(core_argument)
|
|
||||||
.arg("--sha256")
|
|
||||||
.arg(sha256_hex)
|
|
||||||
.output()?
|
|
||||||
} else {
|
|
||||||
let elevator = crate::utils::help::linux_elevator();
|
|
||||||
let mut elevated = StdCommand::new(&elevator);
|
|
||||||
// pkexec-only option; other elevators such as sudo reject unknown flags outright.
|
|
||||||
if elevator.contains("pkexec") {
|
|
||||||
elevated.arg("--disable-internal-agent");
|
|
||||||
}
|
|
||||||
let result = elevated
|
|
||||||
.arg(&install_path)
|
|
||||||
.arg("--install-core")
|
|
||||||
.arg(core_argument)
|
|
||||||
.arg("--sha256")
|
|
||||||
.arg(sha256_hex)
|
|
||||||
.output()?;
|
|
||||||
if !result.status.success() && elevator.contains("pkexec") {
|
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Service,
|
|
||||||
"pkexec failed with code {}, falling back to sudo",
|
|
||||||
result.status.code().unwrap_or(-1)
|
|
||||||
);
|
|
||||||
StdCommand::new("sudo")
|
|
||||||
.arg(&install_path)
|
|
||||||
.arg("--install-core")
|
|
||||||
.arg(core_argument)
|
|
||||||
.arg("--sha256")
|
|
||||||
.arg(sha256_hex)
|
|
||||||
.output()?
|
|
||||||
} else {
|
|
||||||
result
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> {
|
|
||||||
let install_path = packaged_service_tool_path("clash-verge-service-install", || {
|
|
||||||
Ok(dirs::service_path()?.with_file_name("clash-verge-service-install"))
|
|
||||||
})?;
|
|
||||||
if !install_path.exists() {
|
|
||||||
bail!(format!("installer not found: {install_path:?}"));
|
|
||||||
}
|
|
||||||
let install_path = macos_service_tool_path(&install_path)?;
|
|
||||||
|
|
||||||
let prompt = clash_verge_i18n::t!("service.adminInstallPrompt");
|
|
||||||
let shell = format!(
|
|
||||||
"cd /; {} --install-core {} --sha256 {}",
|
|
||||||
shell_single_quote(&install_path.to_string_lossy()),
|
|
||||||
shell_single_quote(&core_path.to_string_lossy()),
|
|
||||||
shell_single_quote(sha256_hex),
|
|
||||||
);
|
|
||||||
let shell = escape_osascript_double_quoted_string(&shell);
|
|
||||||
let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#);
|
|
||||||
|
|
||||||
let output = StdCommand::new("osascript").args(["-e", &command]).output()?;
|
|
||||||
if let Some((code, err)) = check_output_error(&output) {
|
|
||||||
bail!("failed to stage the core for the service, code {code}: {err}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatches a privileged platform operation on a blocking thread.
|
/// Dispatches a privileged platform operation on a blocking thread.
|
||||||
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
|
pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> {
|
||||||
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
|
let (operation, label): (fn() -> Result<()>, &'static str) = match action {
|
||||||
@@ -1042,6 +823,45 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
|
|||||||
.context("Clash Verge Service 未返回运行时暂存结果")
|
.context("Clash Verge Service 未返回运行时暂存结果")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct ServiceStartRefusal {
|
||||||
|
pub code: u16,
|
||||||
|
pub core_path: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ServiceStartRefusal {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"failed to start Service core at {} (code {}): {}",
|
||||||
|
self.core_path, self.code, self.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ServiceStartRefusal {}
|
||||||
|
|
||||||
|
fn record_service_start_refusal<E: RunStateEnv>(
|
||||||
|
store: &RunStateStore<E>,
|
||||||
|
refusal: ServiceStartRefusal,
|
||||||
|
) -> anyhow::Error {
|
||||||
|
// 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
|
/// 尝试使用服务启动core
|
||||||
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
|
#[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<()> {
|
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
|
||||||
@@ -1076,11 +896,24 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
|||||||
response.code,
|
response.code,
|
||||||
err_msg
|
err_msg
|
||||||
);
|
);
|
||||||
|
#[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", "");
|
||||||
|
}
|
||||||
|
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
|
||||||
|
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
|
||||||
|
Handle::notice_message("service_core::app_data_not_owned", "");
|
||||||
|
}
|
||||||
start_owner_monitor();
|
start_owner_monitor();
|
||||||
bail!(
|
return Err(record_service_start_refusal(
|
||||||
"failed to start Service core at {}: {err_msg}",
|
&RUN_STATE,
|
||||||
request.runtime.core_path
|
ServiceStartRefusal {
|
||||||
);
|
code: response.code,
|
||||||
|
core_path: request.runtime.core_path,
|
||||||
|
message: err_msg,
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
let result = response.data.context("Clash Verge Service 未返回会话信息")?;
|
||||||
@@ -1099,6 +932,9 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
|
|||||||
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
|
||||||
start_owner_monitor();
|
start_owner_monitor();
|
||||||
tracing::Span::current().record("outcome", "started");
|
tracing::Span::current().record("outcome", "started");
|
||||||
|
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
|
||||||
|
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
|
||||||
|
PENDING_SERVICE_OWNER_NOTICE.lock().take();
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
@@ -1141,7 +977,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
|
|||||||
|
|
||||||
if response.code > 0 {
|
if response.code > 0 {
|
||||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
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;
|
let err_msg = response.message;
|
||||||
bail!(err_msg);
|
bail!(err_msg);
|
||||||
@@ -1159,7 +995,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
|
|||||||
let response = response.context("无法连接到Clash Verge Service")?;
|
let response = response.context("无法连接到Clash Verge Service")?;
|
||||||
if response.code > 0 {
|
if response.code > 0 {
|
||||||
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
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);
|
bail!(response.message);
|
||||||
}
|
}
|
||||||
@@ -1283,8 +1119,9 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
"provider cache {} was not read: {error:#}",
|
"failed to read provider cache {} into {}: {error:#}",
|
||||||
declared.provider.destination
|
declared.provider.destination,
|
||||||
|
temp.display()
|
||||||
);
|
);
|
||||||
outcome.pending += 1;
|
outcome.pending += 1;
|
||||||
remove_temp(&temp).await;
|
remove_temp(&temp).await;
|
||||||
@@ -1374,8 +1211,9 @@ async fn publish_fetched(
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::Service,
|
Type::Service,
|
||||||
"provider cache {} was not published: {error}",
|
"failed to rename provider cache {} to {}: {error}",
|
||||||
cache.declared.provider.destination
|
cache.temp.display(),
|
||||||
|
target.display()
|
||||||
);
|
);
|
||||||
remove_temp(&cache.temp).await;
|
remove_temp(&cache.temp).await;
|
||||||
}
|
}
|
||||||
@@ -1519,7 +1357,9 @@ fn has_settled(mtime_ns: Option<u64>) -> bool {
|
|||||||
// Exclusive creation protects existing files from truncation and cleanup.
|
// Exclusive creation protects existing files from truncation and cleanup.
|
||||||
async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
||||||
if let Some(parent) = target.parent() {
|
if let Some(parent) = target.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to create provider cache directory {}", parent.display()))?;
|
||||||
}
|
}
|
||||||
let name = target
|
let name = target
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -1688,9 +1528,11 @@ struct OwnerRecoveryPolicy {
|
|||||||
reset_system_proxy: bool,
|
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 {
|
OwnerRecoveryPolicy {
|
||||||
reset_system_proxy: !is_macos,
|
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1712,6 +1554,7 @@ fn start_owner_monitor() {
|
|||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
|
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
|
||||||
let mut watch = OwnerWatch::new();
|
let mut watch = OwnerWatch::new();
|
||||||
|
let mut core_restarts = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
|
||||||
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
|
||||||
@@ -1731,7 +1574,10 @@ fn start_owner_monitor() {
|
|||||||
break;
|
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);
|
let mut step = watch.observe(sample);
|
||||||
if matches!(step, OwnerStep::VerifyTransport) {
|
if matches!(step, OwnerStep::VerifyTransport) {
|
||||||
if watch.just_became_sustained() {
|
if watch.just_became_sustained() {
|
||||||
@@ -1747,15 +1593,41 @@ fn start_owner_monitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let OwnerStep::Recover(reason) = step {
|
if let OwnerStep::Recover(reason) = step {
|
||||||
recover_after_owner_loss(generation, reason).await;
|
recover_after_owner_loss(generation, reason, status.as_ref()).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Service logs its watchdog restarts where the app cannot read them.
|
||||||
|
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
|
||||||
|
if seen.is_some_and(|seen| status.restart_count > seen) {
|
||||||
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::Service,
|
||||||
|
"service restarted the core ({} restarts so far); last exit: {}",
|
||||||
|
status.restart_count,
|
||||||
|
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*seen = Some(status.restart_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
|
||||||
|
let detail = format!(
|
||||||
|
"service state {:?}, {} restarts, last exit: {}",
|
||||||
|
status.service_state,
|
||||||
|
status.restart_count,
|
||||||
|
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
|
||||||
|
);
|
||||||
|
logging!(error, Type::Service, "service core stopped: {detail}");
|
||||||
|
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
|
||||||
|
Handle::notice_message("core_start::error", "");
|
||||||
|
}
|
||||||
|
|
||||||
/// Samples ownership, treating every unusable reply as unreadable.
|
/// 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() {
|
let response = match current_owner_credentials() {
|
||||||
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
|
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
|
||||||
Err(error) => Err(error),
|
Err(error) => Err(error),
|
||||||
@@ -1765,12 +1637,12 @@ async fn read_owner_sample() -> OwnerSample {
|
|||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
logging!(debug, Type::Service, "service owner status was unreadable: {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 {
|
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
|
||||||
return OwnerSample::NotActive;
|
return (OwnerSample::NotActive, None);
|
||||||
}
|
}
|
||||||
if response.code != 0 {
|
if response.code != 0 {
|
||||||
logging!(
|
logging!(
|
||||||
@@ -1780,24 +1652,25 @@ async fn read_owner_sample() -> OwnerSample {
|
|||||||
response.code,
|
response.code,
|
||||||
response.message
|
response.message
|
||||||
);
|
);
|
||||||
return OwnerSample::Unreadable;
|
return (OwnerSample::Unreadable, None);
|
||||||
}
|
}
|
||||||
let Some(status) = response.data else {
|
let Some(status) = response.data else {
|
||||||
logging!(debug, Type::Service, "service owner status omitted data");
|
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.
|
// 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) {
|
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,
|
is_active: status.is_active,
|
||||||
desired_core_should_be_running: status.desired_core_should_be_running,
|
desired_core_should_be_running: status.desired_core_should_be_running,
|
||||||
service_state: status.service_state,
|
service_state: status.service_state,
|
||||||
core_pid: status.core_pid,
|
core_pid: status.core_pid,
|
||||||
}
|
};
|
||||||
|
(sample, Some(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
|
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
|
||||||
@@ -1815,7 +1688,12 @@ pub(crate) fn owner_monitor_generation() -> u64 {
|
|||||||
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
|
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();
|
let manager = CoreManager::global();
|
||||||
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
|
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
|
||||||
return;
|
return;
|
||||||
@@ -1831,6 +1709,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recover_after_owner_loss_while_locked(reason).await;
|
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> {
|
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
|
||||||
@@ -1855,13 +1737,15 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
|||||||
);
|
);
|
||||||
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
|
||||||
proxy_control::stop_guard().await;
|
proxy_control::stop_guard().await;
|
||||||
|
// Clear while still in Service mode with the session: on macOS it routes through the helper.
|
||||||
|
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
||||||
|
clear_proxy_after_owner_loss().await;
|
||||||
|
}
|
||||||
clear_active_service_session();
|
clear_active_service_session();
|
||||||
CoreManager::global().core_stopped();
|
CoreManager::global().core_stopped();
|
||||||
|
}
|
||||||
|
|
||||||
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
|
async fn clear_proxy_after_owner_loss() {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
for attempt in 1..=3 {
|
for attempt in 1..=3 {
|
||||||
match proxy_control::clear().await {
|
match proxy_control::clear().await {
|
||||||
@@ -1891,12 +1775,13 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
|
|||||||
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
|
#[tracing::instrument(skip_all, level = "info", fields(attempts = tracing::field::Empty, interval_ms = tracing::field::Empty, outcome = tracing::field::Empty))]
|
||||||
async fn wait_for_service_ipc() -> Result<()> {
|
async fn wait_for_service_ipc() -> Result<()> {
|
||||||
const CONTEXT: &str = "service IPC did not become available";
|
const CONTEXT: &str = "service IPC did not become available";
|
||||||
let config = ServiceManager::config();
|
const READY_ATTEMPTS: usize = 61;
|
||||||
|
const READY_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
let span = tracing::Span::current();
|
let span = tracing::Span::current();
|
||||||
span.record("attempts", config.max_retries);
|
span.record("attempts", READY_ATTEMPTS);
|
||||||
span.record("interval_ms", config.retry_delay.as_millis() as u64);
|
span.record("interval_ms", READY_INTERVAL.as_millis() as u64);
|
||||||
|
|
||||||
match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await {
|
match RUN_STATE.await_ready(READY_ATTEMPTS, READY_INTERVAL).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::Span::current().record("outcome", "ready");
|
tracing::Span::current().record("outcome", "ready");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2112,8 +1997,8 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
|
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
|
||||||
generate_service_session_token, macos_install_shell, mark_service_unavailable_after_owner_loss,
|
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
|
||||||
owner_recovery_policy, service_core_path_for, session_matches_status,
|
service_core_path_for, session_matches_status,
|
||||||
};
|
};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use super::{service_core_path_for_with_publisher, service_tool_path_for};
|
use super::{service_core_path_for_with_publisher, service_tool_path_for};
|
||||||
@@ -2250,17 +2135,6 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn macos_install_shell_starts_from_root_without_nested_sudo() {
|
|
||||||
let shell = macos_install_shell(Path::new("/safe/service-tools/clash-verge-service-install"), 20);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
shell,
|
|
||||||
"cd /; CLASH_VERGE_SERVICE_GID=20 '/safe/service-tools/clash-verge-service-install'"
|
|
||||||
);
|
|
||||||
assert!(!shell.contains("sudo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> {
|
fn development_service_core_refresh_atomically_replaces_bytes() -> anyhow::Result<()> {
|
||||||
@@ -2387,13 +2261,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 [
|
for reason in [
|
||||||
OwnerRecoveryReason::Displaced,
|
OwnerRecoveryReason::Displaced,
|
||||||
OwnerRecoveryReason::SameOwnerFailure,
|
OwnerRecoveryReason::SameOwnerFailure,
|
||||||
OwnerRecoveryReason::TransportFailure,
|
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);
|
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2419,6 +2296,55 @@ mod tests {
|
|||||||
assert_eq!(store.generation_count(), generation + 1);
|
assert_eq!(store.generation_count(), generation + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refused_core_start_can_continue_with_sidecar() -> anyhow::Result<()> {
|
||||||
|
use clash_verge_service_ipc::ServiceErrorCode;
|
||||||
|
for code in [
|
||||||
|
ServiceErrorCode::InvalidInstallLocation,
|
||||||
|
ServiceErrorCode::InvalidRuntimeAsset,
|
||||||
|
ServiceErrorCode::OwnerSwitchFailed,
|
||||||
|
] {
|
||||||
|
let store = fake_store();
|
||||||
|
store.observe(ServiceHealth::Ready);
|
||||||
|
let error = super::record_service_start_refusal(
|
||||||
|
&store,
|
||||||
|
super::ServiceStartRefusal {
|
||||||
|
code: code as u16,
|
||||||
|
core_path: "/development/service-core/verge-mihomo".into(),
|
||||||
|
message: "no administrator-approved copy is installed".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(error.downcast_ref::<super::ServiceStartRefusal>().is_some());
|
||||||
|
let state = store.settled().await;
|
||||||
|
assert_eq!(state.mode, crate::core::manager::RunningMode::NotRunning);
|
||||||
|
assert!(state.service_needs_attention());
|
||||||
|
assert!(!state.service_usable());
|
||||||
|
assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_)));
|
||||||
|
store.allow_sidecar_for_session()?;
|
||||||
|
assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed);
|
||||||
|
assert!(store.state().tun_should_be_disabled(true));
|
||||||
|
}
|
||||||
|
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]
|
#[test]
|
||||||
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
fn cached_readiness_is_false_while_a_service_operation_is_running() {
|
||||||
let store = fake_store();
|
let store = fake_store();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::{config::Config, singleton, utils::dirs};
|
use crate::{config::Config, singleton, utils::dirs};
|
||||||
use anyhow::Result;
|
use anyhow::{Context as _, Result};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
@@ -47,17 +47,20 @@ impl SilentUpdater {
|
|||||||
|
|
||||||
fn write_cache(bytes: &[u8], version: &str) -> Result<()> {
|
fn write_cache(bytes: &[u8], version: &str) -> Result<()> {
|
||||||
let cache_dir = Self::cache_dir()?;
|
let cache_dir = Self::cache_dir()?;
|
||||||
std::fs::create_dir_all(&cache_dir)?;
|
std::fs::create_dir_all(&cache_dir)
|
||||||
|
.with_context(|| format!("failed to create update cache directory {}", cache_dir.display()))?;
|
||||||
|
|
||||||
let bin_path = cache_dir.join("pending_update.bin");
|
let bin_path = cache_dir.join("pending_update.bin");
|
||||||
std::fs::write(&bin_path, bytes)?;
|
std::fs::write(&bin_path, bytes)
|
||||||
|
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
|
||||||
|
|
||||||
let meta = UpdateCacheMeta {
|
let meta = UpdateCacheMeta {
|
||||||
version: version.to_string(),
|
version: version.to_string(),
|
||||||
downloaded_at: Utc::now().to_rfc3339(),
|
downloaded_at: Utc::now().to_rfc3339(),
|
||||||
};
|
};
|
||||||
let meta_path = cache_dir.join("pending_update.json");
|
let meta_path = cache_dir.join("pending_update.json");
|
||||||
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
|
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)
|
||||||
|
.with_context(|| format!("failed to write update metadata {}", meta_path.display()))?;
|
||||||
|
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
@@ -71,7 +74,7 @@ impl SilentUpdater {
|
|||||||
|
|
||||||
fn read_cache_bytes() -> Result<Vec<u8>> {
|
fn read_cache_bytes() -> Result<Vec<u8>> {
|
||||||
let bin_path = Self::cache_dir()?.join("pending_update.bin");
|
let bin_path = Self::cache_dir()?.join("pending_update.bin");
|
||||||
Ok(std::fs::read(bin_path)?)
|
std::fs::read(&bin_path).with_context(|| format!("failed to read update cache {}", bin_path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_cache_meta() -> Result<UpdateCacheMeta> {
|
fn read_cache_meta() -> Result<UpdateCacheMeta> {
|
||||||
@@ -85,7 +88,12 @@ impl SilentUpdater {
|
|||||||
&& cache_dir.exists()
|
&& cache_dir.exists()
|
||||||
{
|
{
|
||||||
if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
|
if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
|
||||||
logging!(warn, Type::System, "Failed to delete update cache: {e}");
|
logging!(
|
||||||
|
warn,
|
||||||
|
Type::System,
|
||||||
|
"Failed to delete update cache {}: {e}",
|
||||||
|
cache_dir.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logging!(info, Type::System, "Update cache deleted");
|
logging!(info, Type::System, "Update cache deleted");
|
||||||
}
|
}
|
||||||
@@ -93,15 +101,22 @@ impl SilentUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compares numeric version components after stripping `v` and prerelease suffixes.
|
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
|
||||||
|
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!build.is_empty() && !base.contains('-') && base == remote.trim_start_matches('v')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares numeric version components after stripping version suffixes.
|
||||||
fn version_lte(a: &str, b: &str) -> bool {
|
fn version_lte(a: &str, b: &str) -> bool {
|
||||||
let parse = |v: &str| -> Vec<u64> {
|
let parse = |v: &str| -> Vec<u64> {
|
||||||
v.trim_start_matches('v')
|
v.trim_start_matches('v')
|
||||||
|
.split(['-', '+'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("0")
|
||||||
.split('.')
|
.split('.')
|
||||||
.filter_map(|part| {
|
.filter_map(|part| part.parse::<u64>().ok())
|
||||||
let numeric = part.split('-').next().unwrap_or("0");
|
|
||||||
numeric.parse::<u64>().ok()
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,7 +159,7 @@ impl SilentUpdater {
|
|||||||
|
|
||||||
let cached_version = &meta.version;
|
let cached_version = &meta.version;
|
||||||
|
|
||||||
if version_lte(cached_version, current_version) {
|
if !is_build_to_stable(current_version, cached_version) && version_lte(cached_version, current_version) {
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::System,
|
Type::System,
|
||||||
@@ -176,7 +191,7 @@ impl SilentUpdater {
|
|||||||
logging!(
|
logging!(
|
||||||
warn,
|
warn,
|
||||||
Type::System,
|
Type::System,
|
||||||
"Failed to read cached update bytes: {e}, cleaning up"
|
"Failed to read cached update bytes: {e:#}, cleaning up"
|
||||||
);
|
);
|
||||||
Self::delete_cache();
|
Self::delete_cache();
|
||||||
return false;
|
return false;
|
||||||
@@ -456,7 +471,7 @@ impl SilentUpdater {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Err(e) = 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}");
|
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.pending_bytes.write() = Some(bytes);
|
*self.pending_bytes.write() = Some(bytes);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ impl CoreConfigValidator {
|
|||||||
Ok(content) => content,
|
Ok(content) => content,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("Failed to read file: {err}").into();
|
let error_msg: String = format!("Failed to read file: {err}").into();
|
||||||
logging!(error, Type::Validate, "无法读取文件: {}", error_msg);
|
logging!(error, Type::Validate, "Failed to read file {config_path}: {err}");
|
||||||
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,7 +238,7 @@ impl CoreConfigValidator {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("YAML syntax error: {err}").into();
|
let error_msg: String = format!("YAML syntax error: {err}").into();
|
||||||
logging!(error, Type::Validate, "YAML语法错误: {}", error_msg);
|
logging!(error, Type::Validate, "YAML syntax error in {config_path}: {err}");
|
||||||
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ impl CoreConfigValidator {
|
|||||||
Ok(content) => content,
|
Ok(content) => content,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("Failed to read script file: {err}").into();
|
let error_msg: String = format!("Failed to read script file: {err}").into();
|
||||||
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
|
logging!(warn, Type::Validate, "Failed to read script file {path}: {err}");
|
||||||
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
return Ok(ValidationOutcome::invalid_from_message(error_msg));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -291,7 +291,7 @@ impl CoreConfigValidator {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("Script syntax error: {err}").into();
|
let error_msg: String = format!("Script syntax error: {err}").into();
|
||||||
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
|
logging!(warn, Type::Validate, "Script syntax error in {path}: {err}");
|
||||||
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
Ok(ValidationOutcome::invalid_from_message(error_msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use clash_verge_logging::{Type, logging};
|
|
||||||
|
|
||||||
use super::field::use_lowercase;
|
use super::field::use_lowercase;
|
||||||
use serde_yaml_ng::{self, Mapping, Value};
|
use serde_yaml_ng::{self, Mapping, Value};
|
||||||
|
|
||||||
@@ -18,20 +16,19 @@ fn deep_merge(a: &mut Value, b: Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn use_merge(merge: &Mapping, config: Mapping) -> Mapping {
|
pub fn use_merge(merge: &Mapping, mut config: Mapping) -> Mapping {
|
||||||
let mut config = Value::from(config);
|
for (key, value) in use_lowercase(merge) {
|
||||||
let merge = use_lowercase(merge);
|
match (config.get_mut(&key), value) {
|
||||||
|
(Some(Value::Mapping(existing)), Value::Mapping(overlay)) if key.as_str() == Some("dns") => {
|
||||||
deep_merge(&mut config, Value::from(merge));
|
existing.extend(overlay);
|
||||||
|
}
|
||||||
config.as_mapping().cloned().unwrap_or_else(|| {
|
(Some(existing), value) if key.as_str() != Some("hosts") => deep_merge(existing, value),
|
||||||
logging!(
|
(_, value) => {
|
||||||
error,
|
config.insert(key, value);
|
||||||
Type::Core,
|
}
|
||||||
"Failed to convert merged config to mapping, using empty mapping"
|
}
|
||||||
);
|
}
|
||||||
Mapping::new()
|
config
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -54,18 +51,49 @@ fn test_merge() -> anyhow::Result<()> {
|
|||||||
enable: true
|
enable: true
|
||||||
dns:
|
dns:
|
||||||
enable: true
|
enable: true
|
||||||
|
nameserver-policy:
|
||||||
|
new.example: 1.1.1.1
|
||||||
|
fallback-filter:
|
||||||
|
geoip: false
|
||||||
|
hosts:
|
||||||
|
new.example: 192.0.2.2
|
||||||
";
|
";
|
||||||
|
|
||||||
let config = r"
|
let config = r"
|
||||||
rules:
|
rules:
|
||||||
- aaaaa
|
- aaaaa
|
||||||
script1: test
|
script1: test
|
||||||
|
tun:
|
||||||
|
mtu: 1500
|
||||||
|
dns:
|
||||||
|
nameserver: [9.9.9.9]
|
||||||
|
nameserver-policy:
|
||||||
|
old.example: 8.8.8.8
|
||||||
|
fallback-filter:
|
||||||
|
geoip: true
|
||||||
|
domain: [old.example]
|
||||||
|
hosts:
|
||||||
|
old.example: 192.0.2.1
|
||||||
";
|
";
|
||||||
|
|
||||||
let merge = serde_yaml_ng::from_str::<Mapping>(merge)?;
|
let merge = serde_yaml_ng::from_str::<Mapping>(merge)?;
|
||||||
let config = serde_yaml_ng::from_str::<Mapping>(config)?;
|
let config = serde_yaml_ng::from_str::<Mapping>(config)?;
|
||||||
|
|
||||||
let _ = serde_yaml_ng::to_string(&use_merge(&merge, config))?;
|
let result = use_merge(&merge, config);
|
||||||
|
assert_eq!(result["dns"]["nameserver-policy"], merge["dns"]["nameserver-policy"]);
|
||||||
|
assert_eq!(result["dns"]["fallback-filter"], merge["dns"]["fallback-filter"]);
|
||||||
|
assert_eq!(result["hosts"], merge["hosts"]);
|
||||||
|
assert_eq!(result["dns"]["nameserver"], serde_yaml_ng::to_value(["9.9.9.9"])?);
|
||||||
|
assert_eq!(result["tun"]["mtu"], Value::from(1500));
|
||||||
|
assert_eq!(result["rules"], merge["rules"]);
|
||||||
|
assert_eq!(result["script1"], Value::from("test"));
|
||||||
|
|
||||||
|
let cleared = serde_yaml_ng::from_str::<Mapping>(
|
||||||
|
"dns: {enable: false, nameserver-policy: {}, fallback-filter: {}, nameserver: []}\nhosts: {}",
|
||||||
|
)?;
|
||||||
|
let result = use_merge(&cleared, result);
|
||||||
|
assert_eq!(result["dns"], cleared["dns"]);
|
||||||
|
assert_eq!(result["hosts"], cleared["hosts"]);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+322
-97
@@ -14,6 +14,7 @@ use self::{
|
|||||||
tun::use_tun,
|
tun::use_tun,
|
||||||
};
|
};
|
||||||
use crate::config::dns::{DnsOverrideState, dns_override_source};
|
use crate::config::dns::{DnsOverrideState, dns_override_source};
|
||||||
|
use crate::core::handle::Handle;
|
||||||
use crate::utils::dirs;
|
use crate::utils::dirs;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, IProfiles, IVerge, PrfItem},
|
config::{Config, IProfiles, IVerge, PrfItem},
|
||||||
@@ -22,6 +23,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use parking_lot::Mutex;
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -103,7 +105,7 @@ async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config_values() -> ConfigValues {
|
async fn get_config_values(profile_uid: &str) -> ConfigValues {
|
||||||
let clash = Config::clash().await;
|
let clash = Config::clash().await;
|
||||||
let clash_arc = clash.latest_arc();
|
let clash_arc = clash.latest_arc();
|
||||||
let clash_config = clash_arc.0.clone();
|
let clash_config = clash_arc.0.clone();
|
||||||
@@ -118,12 +120,12 @@ async fn get_config_values() -> ConfigValues {
|
|||||||
ref enable_builtin_enhanced,
|
ref enable_builtin_enhanced,
|
||||||
ref verge_socks_enabled,
|
ref verge_socks_enabled,
|
||||||
ref verge_http_enabled,
|
ref verge_http_enabled,
|
||||||
ref enable_dns_settings,
|
|
||||||
ref enable_external_controller,
|
ref enable_external_controller,
|
||||||
..
|
..
|
||||||
} = **verge_arc;
|
} = **verge_arc;
|
||||||
let enable_external_controller = enable_external_controller.unwrap_or(false);
|
let enable_external_controller = enable_external_controller.unwrap_or(false);
|
||||||
let dns_override_confirmation = verge_arc.dns_override_confirmation.clone();
|
let dns_settings = verge_arc.dns_settings_for(profile_uid);
|
||||||
|
let dns_override_confirmation = dns_settings.confirmation;
|
||||||
|
|
||||||
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||||
Some(verge_arc.get_valid_clash_core()),
|
Some(verge_arc.get_valid_clash_core()),
|
||||||
@@ -131,7 +133,7 @@ async fn get_config_values() -> ConfigValues {
|
|||||||
enable_builtin_enhanced.unwrap_or(true),
|
enable_builtin_enhanced.unwrap_or(true),
|
||||||
verge_socks_enabled.unwrap_or(false),
|
verge_socks_enabled.unwrap_or(false),
|
||||||
verge_http_enabled.unwrap_or(false),
|
verge_http_enabled.unwrap_or(false),
|
||||||
enable_dns_settings.unwrap_or(false),
|
dns_settings.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
@@ -247,16 +249,33 @@ async fn process_global_items(
|
|||||||
global_merge: ChainItem,
|
global_merge: ChainItem,
|
||||||
global_script: ChainItem,
|
global_script: ChainItem,
|
||||||
profile_name: &String,
|
profile_name: &String,
|
||||||
|
authoritative: &AuthoritativeFields,
|
||||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||||
if let ChainType::Merge(merge) = global_merge.data {
|
if let ChainType::Merge(merge) = global_merge.data {
|
||||||
exists_keys.extend(use_keys(&merge));
|
exists_keys.extend(use_keys(&merge));
|
||||||
|
let before = authoritative.current(&config);
|
||||||
config = use_merge(&merge, config);
|
config = use_merge(&merge, config);
|
||||||
|
let notes: ResultLog = authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note)
|
||||||
|
.collect();
|
||||||
|
if !notes.is_empty() {
|
||||||
|
result_map.entry(global_merge.uid).or_default().extend(notes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ChainType::Script(script) = global_script.data {
|
if let ChainType::Script(script) = global_script.data {
|
||||||
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
|
let before = authoritative.current(&config);
|
||||||
|
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||||
exists_keys.extend(changed_keys);
|
exists_keys.extend(changed_keys);
|
||||||
config = res_config;
|
config = res_config;
|
||||||
|
logs.extend(
|
||||||
|
authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note),
|
||||||
|
);
|
||||||
result_map.insert(global_script.uid, logs);
|
result_map.insert(global_script.uid, logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,8 +303,7 @@ fn process_seq_items(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
|
/// App-owned control-plane keys; DNS settings are captured separately.
|
||||||
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
|
|
||||||
const CONTROL_PLANE_KEYS: &[&str] = &[
|
const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||||
"external-controller",
|
"external-controller",
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -308,32 +326,117 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
|
|||||||
"unified-delay",
|
"unified-delay",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The fields the app owns, held across the stages that apply the user's manual overrides.
|
/// App-owned fields: captured after the app derives them, enforced after every override.
|
||||||
///
|
|
||||||
/// Capturing and restoring are one value rather than two calls because the order is a
|
|
||||||
/// correctness requirement, not a style: capture must happen after the app has finished
|
|
||||||
/// deriving these fields and before any override runs, and restore must happen after every
|
|
||||||
/// override. As four separate calls that contract lived only in comments.
|
|
||||||
struct AuthoritativeFields {
|
struct AuthoritativeFields {
|
||||||
control_plane: Mapping,
|
control_plane: Mapping,
|
||||||
tun: Mapping,
|
tun: Mapping,
|
||||||
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
|
dns: Mapping,
|
||||||
dns_ipv6: Option<Value>,
|
hosts: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthoritativeFields {
|
impl AuthoritativeFields {
|
||||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
|
fn capture(config: &Mapping, gui_tun_keys: &[Value], mut dns_settings: Mapping) -> Self {
|
||||||
Self {
|
Self {
|
||||||
control_plane: snapshot_control_plane(config),
|
control_plane: snapshot_control_plane(config),
|
||||||
tun: snapshot_tun(config, gui_tun_keys),
|
tun: snapshot_tun(config, gui_tun_keys),
|
||||||
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
|
dns: take_mapping(&mut dns_settings, "dns"),
|
||||||
|
hosts: dns_settings.remove("hosts"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce(self, config: Mapping) -> Mapping {
|
fn enforce(self, config: Mapping) -> Mapping {
|
||||||
let config = enforce_control_plane(config, self.control_plane);
|
let config = enforce_control_plane(config, self.control_plane);
|
||||||
let config = enforce_tun(config, self.tun);
|
let mut config = enforce_tun(config, self.tun);
|
||||||
enforce_dns_ipv6(config, self.dns_ipv6)
|
if !self.dns.is_empty() {
|
||||||
|
let mut dns = take_mapping(&mut config, "dns");
|
||||||
|
dns.extend(self.dns);
|
||||||
|
config.insert("dns".into(), dns.into());
|
||||||
|
}
|
||||||
|
if let Some(hosts) = self.hosts {
|
||||||
|
config.insert("hosts".into(), hosts);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owned values as `config` holds them now, for diffing one override.
|
||||||
|
fn current(&self, config: &Mapping) -> Self {
|
||||||
|
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
|
||||||
|
let dns = config.get("dns").and_then(Value::as_mapping);
|
||||||
|
Self {
|
||||||
|
control_plane: snapshot_control_plane(config),
|
||||||
|
tun: snapshot_tun(config, &tun_keys),
|
||||||
|
dns: self
|
||||||
|
.dns
|
||||||
|
.keys()
|
||||||
|
.filter_map(|key| Some((key.clone(), dns?.get(key)?.clone())))
|
||||||
|
.collect(),
|
||||||
|
hosts: self.hosts.as_ref().and_then(|_| config.get("hosts")).cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owned keys an override moved off the app value; `enforce` discards those writes.
|
||||||
|
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for &key in CONTROL_PLANE_KEYS {
|
||||||
|
if before.control_plane.get(key) != after.control_plane.get(key)
|
||||||
|
&& after.control_plane.get(key) != self.control_plane.get(key)
|
||||||
|
{
|
||||||
|
keys.push(key.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in self.tun.keys() {
|
||||||
|
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
|
||||||
|
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key in self.dns.keys() {
|
||||||
|
if before.dns.get(key) != after.dns.get(key) && after.dns.get(key) != self.dns.get(key) {
|
||||||
|
keys.push(format!("dns.{}", key.as_str().unwrap_or_default()).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.hosts.is_some() && before.hosts != after.hosts && after.hosts != self.hosts {
|
||||||
|
keys.push("hosts".into());
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discarded_note(key: String) -> (String, String) {
|
||||||
|
(
|
||||||
|
"warn".into(),
|
||||||
|
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscardedKeysNotice {
|
||||||
|
pending: Option<String>,
|
||||||
|
last: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
|
||||||
|
pending: None,
|
||||||
|
last: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
|
||||||
|
DISCARDED_KEYS_NOTICE.lock().pending.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
|
||||||
|
fn notify_discarded_keys(keys: Vec<String>) {
|
||||||
|
let mut notice = DISCARDED_KEYS_NOTICE.lock();
|
||||||
|
if notice.last == keys {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let should_notify = !keys.is_empty();
|
||||||
|
if should_notify {
|
||||||
|
notice.pending = Some(keys.join(", ").into());
|
||||||
|
}
|
||||||
|
notice.last = keys;
|
||||||
|
drop(notice);
|
||||||
|
|
||||||
|
if should_notify {
|
||||||
|
Handle::notice_message("enhance::discarded_keys", "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,21 +504,6 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
|
|
||||||
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
|
|
||||||
config.get("dns")?.get("ipv6").cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
|
|
||||||
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
|
|
||||||
if let Some(dns_ipv6) = dns_ipv6
|
|
||||||
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
|
|
||||||
{
|
|
||||||
dns.insert(Value::from("ipv6"), dns_ipv6);
|
|
||||||
}
|
|
||||||
config
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_loopback_bind_address(addr: &str) -> bool {
|
fn is_loopback_bind_address(addr: &str) -> bool {
|
||||||
let addr = addr.trim();
|
let addr = addr.trim();
|
||||||
let addr = addr
|
let addr = addr
|
||||||
@@ -465,27 +553,40 @@ async fn process_profile_items(
|
|||||||
merge_item: ChainItem,
|
merge_item: ChainItem,
|
||||||
script_item: ChainItem,
|
script_item: ChainItem,
|
||||||
profile_name: &String,
|
profile_name: &String,
|
||||||
|
authoritative: &AuthoritativeFields,
|
||||||
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
|
||||||
if let ChainType::Merge(merge) = merge_item.data {
|
if let ChainType::Merge(merge) = merge_item.data {
|
||||||
exists_keys.extend(use_keys(&merge));
|
exists_keys.extend(use_keys(&merge));
|
||||||
|
let before = authoritative.current(&config);
|
||||||
config = use_merge(&merge, config);
|
config = use_merge(&merge, config);
|
||||||
|
let notes: ResultLog = authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note)
|
||||||
|
.collect();
|
||||||
|
if !notes.is_empty() {
|
||||||
|
result_map.entry(merge_item.uid).or_default().extend(notes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ChainType::Script(script) = script_item.data {
|
if let ChainType::Script(script) = script_item.data {
|
||||||
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
|
let before = authoritative.current(&config);
|
||||||
|
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
|
||||||
exists_keys.extend(changed_keys);
|
exists_keys.extend(changed_keys);
|
||||||
config = res_config;
|
config = res_config;
|
||||||
|
logs.extend(
|
||||||
|
authoritative
|
||||||
|
.overridden(&before, &authoritative.current(&config))
|
||||||
|
.into_iter()
|
||||||
|
.map(discarded_note),
|
||||||
|
);
|
||||||
result_map.insert(script_item.uid, logs);
|
result_map.insert(script_item.uid, logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
(config, exists_keys, result_map)
|
(config, exists_keys, result_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge the Application Merge Config over the profile's own configuration.
|
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||||
///
|
|
||||||
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
|
|
||||||
/// out of the global config from inside its loop, which made a stage that looks pure depend
|
|
||||||
/// on process state and left it untestable.
|
|
||||||
fn merge_default_config(
|
fn merge_default_config(
|
||||||
mut config: Mapping,
|
mut config: Mapping,
|
||||||
clash_config: Mapping,
|
clash_config: Mapping,
|
||||||
@@ -683,9 +784,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
|
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
|
||||||
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
|
|
||||||
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
|
|
||||||
fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||||
use serde_yaml_ng::Value;
|
use serde_yaml_ng::Value;
|
||||||
|
|
||||||
@@ -708,45 +807,78 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
|
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
|
||||||
|
fn is_set(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Null => false,
|
||||||
|
Value::Bool(on) => *on,
|
||||||
|
Value::String(text) => !text.trim().is_empty(),
|
||||||
|
Value::Sequence(items) => !items.is_empty(),
|
||||||
|
Value::Mapping(map) => !map.is_empty(),
|
||||||
|
Value::Number(_) | Value::Tagged(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||||
|
config
|
||||||
|
.get_mut(key)
|
||||||
|
.and_then(Value::as_mapping_mut)
|
||||||
|
.map(std::mem::take)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
|
||||||
|
let mut applied = Mapping::new();
|
||||||
|
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
|
||||||
|
&& !hosts.is_empty()
|
||||||
|
{
|
||||||
|
applied.insert("hosts".into(), hosts.clone().into());
|
||||||
|
config.insert("hosts".into(), hosts.into());
|
||||||
|
logging!(debug, Type::Core, "apply hosts configuration");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy layout: no `dns` root.
|
||||||
|
let dns_override = match dns_config.remove("dns") {
|
||||||
|
Some(Value::Mapping(dns)) => Some(dns),
|
||||||
|
Some(_) => None,
|
||||||
|
None => Some(dns_config),
|
||||||
|
};
|
||||||
|
if let Some(mut dns_override) = dns_override {
|
||||||
|
dns_override.retain(|_, value| is_set(value));
|
||||||
|
if !dns_override.is_empty() {
|
||||||
|
applied.insert("dns".into(), dns_override.clone().into());
|
||||||
|
}
|
||||||
|
let mut dns = take_mapping(&mut config, "dns");
|
||||||
|
dns.extend(dns_override);
|
||||||
|
ensure_fake_ip_range6(&mut dns);
|
||||||
|
config.insert("dns".into(), dns.into());
|
||||||
|
logging!(debug, Type::Core, "apply dns_config.yaml");
|
||||||
|
}
|
||||||
|
(config, applied)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the merged config and the non-empty fields owned by the DNS page.
|
||||||
|
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, Mapping) {
|
||||||
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
||||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||||
|
|
||||||
if dns_path.exists()
|
if dns_path.exists()
|
||||||
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
|
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
|
||||||
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
&& let Ok(dns_config) = serde_yaml_ng::from_str::<Mapping>(&dns_yaml)
|
||||||
{
|
{
|
||||||
if let Some(hosts_value) = dns_config.get("hosts")
|
return merge_dns_config(config, dns_config);
|
||||||
&& hosts_value.is_mapping()
|
|
||||||
{
|
|
||||||
config.insert("hosts".into(), hosts_value.clone());
|
|
||||||
logging!(debug, Type::Core, "apply hosts configuration");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(dns_value) = dns_config.get("dns") {
|
|
||||||
if let Some(dns_mapping) = dns_value.as_mapping() {
|
|
||||||
let mut dns_mapping = dns_mapping.clone();
|
|
||||||
ensure_fake_ip_range6(&mut dns_mapping);
|
|
||||||
config.insert("dns".into(), dns_mapping.into());
|
|
||||||
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut dns_config = dns_config;
|
|
||||||
ensure_fake_ip_range6(&mut dns_config);
|
|
||||||
config.insert("dns".into(), dns_config.into());
|
|
||||||
logging!(debug, Type::Core, "apply dns_config.yaml");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config
|
(config, Mapping::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
|
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
|
||||||
pub async fn enhance(
|
pub async fn enhance(
|
||||||
profiles: &IProfiles,
|
profiles: &IProfiles,
|
||||||
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
|
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
|
||||||
let cfg_vals = get_config_values().await;
|
let profile_uid = profiles.current.as_deref().unwrap_or_default();
|
||||||
|
let cfg_vals = get_config_values(profile_uid).await;
|
||||||
let ConfigValues {
|
let ConfigValues {
|
||||||
clash_config,
|
clash_config,
|
||||||
clash_core,
|
clash_core,
|
||||||
@@ -765,7 +897,8 @@ pub async fn enhance(
|
|||||||
|
|
||||||
let profile = collect_profile_items(profiles).await?;
|
let profile = collect_profile_items(profiles).await?;
|
||||||
let dns_override = DnsOverrideState::new(
|
let dns_override = DnsOverrideState::new(
|
||||||
dns_override_source(profiles.current.as_deref().unwrap_or_default(), &profile.config)?,
|
profile_uid,
|
||||||
|
dns_override_source(profile_uid, &profile.config)?,
|
||||||
enable_dns_settings,
|
enable_dns_settings,
|
||||||
dns_override_confirmation,
|
dns_override_confirmation,
|
||||||
);
|
);
|
||||||
@@ -800,9 +933,9 @@ pub async fn enhance(
|
|||||||
|
|
||||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
||||||
let config = use_tun(config, enable_tun);
|
let config = use_tun(config, enable_tun);
|
||||||
let config = apply_dns_settings(config, enable_dns_settings).await;
|
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
|
||||||
|
|
||||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
|
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
|
||||||
|
|
||||||
let (config, exists_keys, result_map) = process_global_items(
|
let (config, exists_keys, result_map) = process_global_items(
|
||||||
config,
|
config,
|
||||||
@@ -811,12 +944,22 @@ pub async fn enhance(
|
|||||||
global_merge,
|
global_merge,
|
||||||
global_script,
|
global_script,
|
||||||
&profile_name,
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let (config, exists_keys, result_map) =
|
let (config, exists_keys, result_map) = process_profile_items(
|
||||||
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
|
config,
|
||||||
|
exists_keys,
|
||||||
|
result_map,
|
||||||
|
merge_item,
|
||||||
|
script_item,
|
||||||
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
|
||||||
let config = authoritative.enforce(config);
|
let config = authoritative.enforce(config);
|
||||||
let config = ensure_lan_bind_address(config);
|
let config = ensure_lan_bind_address(config);
|
||||||
|
|
||||||
@@ -864,8 +1007,7 @@ mod fake_ip_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hand_edited_empty_range_counts_as_missing() {
|
fn a_hand_edited_empty_range_counts_as_missing() {
|
||||||
// The reason this is not just a `contains_key` check: YAML edited by hand often
|
// Hand-edited YAML may leave the key present but empty.
|
||||||
// leaves the key present with nothing after the colon.
|
|
||||||
for blank in ["", " "] {
|
for blank in ["", " "] {
|
||||||
let mut config = dns(&[
|
let mut config = dns(&[
|
||||||
("ipv6", Value::from(true)),
|
("ipv6", Value::from(true)),
|
||||||
@@ -1004,8 +1146,7 @@ mod use_tun_tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn switching_tun_off_leaves_dns_untouched() {
|
async fn switching_tun_off_leaves_dns_untouched() {
|
||||||
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
|
// TUN off must not rewrite the DNS the profile chose.
|
||||||
// resolver is restored, which is not part of the configuration.
|
|
||||||
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
|
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
|
||||||
|
|
||||||
let result = use_tun(config, false);
|
let result = use_tun(config, false);
|
||||||
@@ -1038,8 +1179,7 @@ mod use_sort_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_bulky_list_fields_are_written_last() {
|
fn the_bulky_list_fields_are_written_last() {
|
||||||
// These are the fields that make a config file unreadable from the top, so they are
|
// Bulky list fields go last so the top of the file stays readable.
|
||||||
// pushed below the settings a human actually scans for.
|
|
||||||
let mut config = Mapping::new();
|
let mut config = Mapping::new();
|
||||||
config.insert(Value::from("rules"), Value::from("rules"));
|
config.insert(Value::from("rules"), Value::from("rules"));
|
||||||
config.insert(Value::from("mode"), Value::from("rule"));
|
config.insert(Value::from("mode"), Value::from("rule"));
|
||||||
@@ -1129,8 +1269,7 @@ mod merge_default_config_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_disabled_listener_is_removed_rather_than_merged() {
|
fn a_disabled_listener_is_removed_rather_than_merged() {
|
||||||
// Removed, not just skipped: the profile may have asked for the port itself, and a
|
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
|
||||||
// listener the user switched off must not be opened by the profile's own value.
|
|
||||||
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
|
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
|
||||||
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
|
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
|
||||||
|
|
||||||
@@ -1152,8 +1291,7 @@ mod merge_default_config_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
|
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
|
||||||
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
|
// Blanked, not removed: an absent key makes mihomo listen on its default.
|
||||||
// listen anyway, which is the opposite of what switching it off means.
|
|
||||||
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
||||||
|
|
||||||
let merged = merge(Mapping::new(), clash_config, false);
|
let merged = merge(Mapping::new(), clash_config, false);
|
||||||
@@ -1223,7 +1361,7 @@ mod authoritative_field_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_override_cannot_change_a_field_the_app_owns() {
|
fn an_override_cannot_change_a_field_the_app_owns() {
|
||||||
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
|
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
|
||||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||||
|
|
||||||
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
|
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
|
||||||
let result = authoritative.enforce(overridden);
|
let result = authoritative.enforce(overridden);
|
||||||
@@ -1236,7 +1374,7 @@ mod authoritative_field_tests {
|
|||||||
fn an_override_cannot_introduce_a_field_the_app_left_out() {
|
fn an_override_cannot_introduce_a_field_the_app_left_out() {
|
||||||
// The app decided not to expose the external controller; a profile must not re-add it.
|
// The app decided not to expose the external controller; a profile must not re-add it.
|
||||||
let derived = Mapping::new();
|
let derived = Mapping::new();
|
||||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||||
|
|
||||||
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
|
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
|
||||||
let result = authoritative.enforce(overridden);
|
let result = authoritative.enforce(overridden);
|
||||||
@@ -1247,7 +1385,7 @@ mod authoritative_field_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn fields_the_app_does_not_own_survive_an_override() {
|
fn fields_the_app_does_not_own_survive_an_override() {
|
||||||
let derived = config_with(&[("mode", Value::from("rule"))]);
|
let derived = config_with(&[("mode", Value::from("rule"))]);
|
||||||
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
|
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||||
|
|
||||||
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
|
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
|
||||||
let result = authoritative.enforce(overridden);
|
let result = authoritative.enforce(overridden);
|
||||||
@@ -1259,7 +1397,7 @@ mod authoritative_field_tests {
|
|||||||
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
|
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
|
||||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||||
|
|
||||||
let owned = AuthoritativeFields::capture(&derived, &[], true);
|
let owned = AuthoritativeFields::capture(&derived, &[], derived.clone());
|
||||||
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||||
@@ -1267,7 +1405,7 @@ mod authoritative_field_tests {
|
|||||||
"with the DNS page on, the app's value wins"
|
"with the DNS page on, the app's value wins"
|
||||||
);
|
);
|
||||||
|
|
||||||
let unowned = AuthoritativeFields::capture(&derived, &[], false);
|
let unowned = AuthoritativeFields::capture(&derived, &[], Mapping::new());
|
||||||
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||||
@@ -1277,14 +1415,13 @@ mod authoritative_field_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restoring_dns_ipv6_never_invents_a_dns_block() {
|
fn restoring_dns_settings_reinstates_a_removed_dns_block() {
|
||||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||||
let authoritative = AuthoritativeFields::capture(&derived, &[], true);
|
let authoritative = AuthoritativeFields::capture(&derived, &[], derived.clone());
|
||||||
|
|
||||||
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
|
|
||||||
let result = authoritative.enforce(Mapping::new());
|
let result = authoritative.enforce(Mapping::new());
|
||||||
|
|
||||||
assert!(!result.contains_key(Value::from("dns")));
|
assert_eq!(result["dns"], derived["dns"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,8 +1429,8 @@ mod authoritative_field_tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
|
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
|
||||||
process_profile_items, use_keys,
|
process_global_items, process_profile_items, use_keys,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -1301,6 +1438,89 @@ mod tests {
|
|||||||
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
|
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dns_settings_override_only_filled_fields_after_merge_and_script() {
|
||||||
|
let profile = mapping(
|
||||||
|
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
|
||||||
|
hosts: {profile.example: 192.0.2.1}",
|
||||||
|
);
|
||||||
|
let settings = mapping(
|
||||||
|
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8},\n\
|
||||||
|
proxy-server-nameserver: [], fallback: null, listen: ' '}\n\
|
||||||
|
hosts: {settings.example: 192.0.2.2}",
|
||||||
|
);
|
||||||
|
let merge = mapping(
|
||||||
|
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
|
||||||
|
hosts: {merge.example: 192.0.2.3}",
|
||||||
|
);
|
||||||
|
let script = r#"function main(config) {
|
||||||
|
config.dns = {
|
||||||
|
ipv6: false,
|
||||||
|
nameserver: ["4.4.4.4"],
|
||||||
|
"nameserver-policy": {"script.example": "4.4.4.4"},
|
||||||
|
"proxy-server-nameserver": ["4.4.4.4"],
|
||||||
|
fallback: ["4.4.4.4"],
|
||||||
|
listen: "127.0.0.1:1053"
|
||||||
|
};
|
||||||
|
config.hosts = {"script.example": "192.0.2.4"};
|
||||||
|
return config;
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
for enabled in [false, true] {
|
||||||
|
let (config, dns_settings) = if enabled {
|
||||||
|
super::merge_dns_config(profile.clone(), settings.clone())
|
||||||
|
} else {
|
||||||
|
(profile.clone(), Mapping::new())
|
||||||
|
};
|
||||||
|
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
|
||||||
|
let merge_item = |uid: &str| ChainItem {
|
||||||
|
uid: uid.into(),
|
||||||
|
data: ChainType::Merge(merge.clone()),
|
||||||
|
};
|
||||||
|
let profile_name = "test-profile".into();
|
||||||
|
let (config, keys, logs) = process_global_items(
|
||||||
|
config,
|
||||||
|
vec![],
|
||||||
|
HashMap::new(),
|
||||||
|
merge_item("global-merge"),
|
||||||
|
ChainItem::to_script("global-script", script),
|
||||||
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (config, _, logs) = process_profile_items(
|
||||||
|
config,
|
||||||
|
keys,
|
||||||
|
logs,
|
||||||
|
merge_item("profile-merge"),
|
||||||
|
ChainItem::to_script("profile-script", script),
|
||||||
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let manual = config.clone();
|
||||||
|
let result = authoritative.enforce(config);
|
||||||
|
|
||||||
|
let source = if enabled { &settings } else { &manual };
|
||||||
|
assert_eq!(result["dns"]["nameserver-policy"], source["dns"]["nameserver-policy"]);
|
||||||
|
assert_eq!(result["hosts"], source["hosts"]);
|
||||||
|
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
|
||||||
|
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
|
||||||
|
for key in ["proxy-server-nameserver", "fallback", "listen"] {
|
||||||
|
assert_eq!(result["dns"][key], manual["dns"][key]);
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
for uid in ["global-merge", "global-script", "profile-merge", "profile-script"] {
|
||||||
|
assert!(
|
||||||
|
logs[uid]
|
||||||
|
.iter()
|
||||||
|
.any(|(_, message)| message.contains("dns.nameserver-policy"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manual_overrides_follow_expected_priority() {
|
async fn manual_overrides_follow_expected_priority() {
|
||||||
let mut config = mapping(
|
let mut config = mapping(
|
||||||
@@ -1345,6 +1565,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let profile_name = "test-profile".into();
|
let profile_name = "test-profile".into();
|
||||||
|
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
|
||||||
let (config, exists_keys, result_map) = process_global_items(
|
let (config, exists_keys, result_map) = process_global_items(
|
||||||
config,
|
config,
|
||||||
exists_keys,
|
exists_keys,
|
||||||
@@ -1352,6 +1573,7 @@ mod tests {
|
|||||||
global_merge,
|
global_merge,
|
||||||
global_script,
|
global_script,
|
||||||
&profile_name,
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (config, exists_keys, _) = process_profile_items(
|
let (config, exists_keys, _) = process_profile_items(
|
||||||
@@ -1361,6 +1583,7 @@ mod tests {
|
|||||||
profile_merge,
|
profile_merge,
|
||||||
profile_script,
|
profile_script,
|
||||||
&profile_name,
|
&profile_name,
|
||||||
|
&authoritative,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1498,19 +1721,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
|
fn empty_dns_settings_leave_inherited_fields_overridable() {
|
||||||
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
|
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
|
||||||
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
|
let (config, dns_settings) =
|
||||||
|
super::merge_dns_config(app_config, mapping("dns: {ipv6: false, proxy-server-nameserver: []}"));
|
||||||
|
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
|
||||||
|
|
||||||
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
|
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
|
||||||
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
|
let result = authoritative.enforce(hijacked);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result
|
result
|
||||||
.get("dns")
|
.get("dns")
|
||||||
.and_then(|value| value.get("ipv6"))
|
.and_then(|value| value.get("ipv6"))
|
||||||
.and_then(serde_yaml_ng::Value::as_bool),
|
.and_then(serde_yaml_ng::Value::as_bool),
|
||||||
Some(false)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use anyhow::Result;
|
use anyhow::{Result, ensure};
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{Config, dns::dns_override_source, profiles::PROFILE_WRITE_LOCK},
|
config::{
|
||||||
|
Config,
|
||||||
|
dns::{ProfileDnsSettings, dns_override_source},
|
||||||
|
profiles::PROFILE_WRITE_LOCK,
|
||||||
|
},
|
||||||
constants::files::DNS_CONFIG,
|
constants::files::DNS_CONFIG,
|
||||||
core::{CoreManager, handle::Handle},
|
core::{CoreManager, handle::Handle},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
@@ -17,10 +21,18 @@ pub enum DnsOverrideOutcome {
|
|||||||
ConfirmationRequired { source: String },
|
ConfirmationRequired { source: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Result<DnsOverrideOutcome> {
|
pub async fn set_dns_override(
|
||||||
|
profile_uid: String,
|
||||||
|
enabled: bool,
|
||||||
|
confirmation: Option<String>,
|
||||||
|
) -> Result<DnsOverrideOutcome> {
|
||||||
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
|
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
|
||||||
let _config_write = Config::lock_config_write().await;
|
let _config_write = Config::lock_config_write().await;
|
||||||
let profiles = Config::profiles().await.data_arc();
|
let profiles = Config::profiles().await.data_arc();
|
||||||
|
ensure!(
|
||||||
|
profiles.current.as_ref() == Some(&profile_uid),
|
||||||
|
"The active profile changed; retry the DNS setting"
|
||||||
|
);
|
||||||
let source = dns_override_source(
|
let source = dns_override_source(
|
||||||
profiles.current.as_deref().unwrap_or_default(),
|
profiles.current.as_deref().unwrap_or_default(),
|
||||||
&profiles.current_mapping().await?,
|
&profiles.current_mapping().await?,
|
||||||
@@ -37,8 +49,13 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
|
|||||||
let verge = Config::verge().await;
|
let verge = Config::verge().await;
|
||||||
let transaction = DraftTransaction::begin(vec![&verge])?;
|
let transaction = DraftTransaction::begin(vec![&verge])?;
|
||||||
verge.edit_draft(|draft| {
|
verge.edit_draft(|draft| {
|
||||||
draft.enable_dns_settings = Some(enabled);
|
draft.profile_dns_settings.insert(
|
||||||
draft.dns_override_confirmation = if enabled { source } else { None };
|
profile_uid.clone(),
|
||||||
|
ProfileDnsSettings {
|
||||||
|
enabled,
|
||||||
|
confirmation: if enabled { source } else { None },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
CoreManager::global().update_config_checked().await?;
|
CoreManager::global().update_config_checked().await?;
|
||||||
|
|
||||||
@@ -47,8 +64,9 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
|
|||||||
// A subscription update can replace the file while Mihomo is validating it.
|
// A subscription update can replace the file while Mihomo is validating it.
|
||||||
if enabled && let Some(state) = state.filter(|state| !state.enabled) {
|
if enabled && let Some(state) = state.filter(|state| !state.enabled) {
|
||||||
verge.edit_draft(|draft| {
|
verge.edit_draft(|draft| {
|
||||||
draft.enable_dns_settings = Some(false);
|
draft
|
||||||
draft.dns_override_confirmation = None;
|
.profile_dns_settings
|
||||||
|
.insert(profile_uid, ProfileDnsSettings::default());
|
||||||
});
|
});
|
||||||
if let Some(source) = state.source {
|
if let Some(source) = state.source {
|
||||||
outcome = DnsOverrideOutcome::ConfirmationRequired { source };
|
outcome = DnsOverrideOutcome::ConfirmationRequired { source };
|
||||||
|
|||||||
+13
-1
@@ -44,7 +44,14 @@ mod app_init {
|
|||||||
let mut builder = builder
|
let mut builder = builder
|
||||||
.plugin(tauri_plugin_clash_verge_sysinfo::init())
|
.plugin(tauri_plugin_clash_verge_sysinfo::init())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(
|
||||||
|
tauri_plugin_updater::Builder::new()
|
||||||
|
.default_version_comparator(|current, release| {
|
||||||
|
release.version > current
|
||||||
|
|| core::updater::is_build_to_stable(¤t.to_string(), &release.version.to_string())
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
@@ -163,6 +170,11 @@ mod app_init {
|
|||||||
cmd::apply_dns_config,
|
cmd::apply_dns_config,
|
||||||
cmd::set_dns_override,
|
cmd::set_dns_override,
|
||||||
cmd::take_dns_override_notice,
|
cmd::take_dns_override_notice,
|
||||||
|
cmd::take_service_fallback_notice,
|
||||||
|
cmd::get_core_startup_error,
|
||||||
|
cmd::take_service_repair_notice,
|
||||||
|
cmd::take_service_owner_notice,
|
||||||
|
cmd::take_discarded_keys_notice,
|
||||||
cmd::get_dns_config_content,
|
cmd::get_dns_config_content,
|
||||||
cmd::validate_dns_config,
|
cmd::validate_dns_config,
|
||||||
cmd::get_clash_logs,
|
cmd::get_clash_logs,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ pub async fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T> {
|
|||||||
bail!("file not found \"{}\"", path.display());
|
bail!("file not found \"{}\"", path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
let yaml_str = tokio::fs::read_to_string(path).await?;
|
let yaml_str = tokio::fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to read YAML file {}", path.display()))?;
|
||||||
|
|
||||||
Ok(with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) }).await?)
|
with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) })
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to parse YAML file {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_mapping(path: &Path) -> Result<Mapping> {
|
pub async fn read_mapping(path: &Path) -> Result<Mapping> {
|
||||||
|
|||||||
+35
-29
@@ -9,7 +9,7 @@ use crate::{
|
|||||||
help,
|
help,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::{Context as _, Result};
|
||||||
use chrono::{Local, TimeZone as _};
|
use chrono::{Local, TimeZone as _};
|
||||||
use clash_verge_logging::Type;
|
use clash_verge_logging::Type;
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
@@ -28,8 +28,15 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
|
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
|
||||||
let mut entries = fs::read_dir(temp_dir).await?;
|
let mut entries = fs::read_dir(temp_dir)
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
.await
|
||||||
|
.with_context(|| format!("failed to read log snapshot directory {}", temp_dir.display()))?;
|
||||||
|
while let Some(entry) = entries.next_entry().await.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to read next entry in log snapshot directory {}",
|
||||||
|
temp_dir.display()
|
||||||
|
)
|
||||||
|
})? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().and_then(|s| s.to_str()) == Some("log") {
|
if path.extension().and_then(|s| s.to_str()) == Some("log") {
|
||||||
let _ = path.remove_if_exists().await;
|
let _ = path.remove_if_exists().await;
|
||||||
@@ -109,15 +116,28 @@ pub async fn delete_log() -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if log_dir.exists() {
|
if log_dir.exists() {
|
||||||
let mut log_read_dir = fs::read_dir(&log_dir).await?;
|
let mut log_read_dir = fs::read_dir(&log_dir)
|
||||||
while let Some(entry) = log_read_dir.next_entry().await? {
|
.await
|
||||||
|
.with_context(|| format!("failed to read log directory {}", log_dir.display()))?;
|
||||||
|
while let Some(entry) = log_read_dir
|
||||||
|
.next_entry()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to read next entry in log directory {}", log_dir.display()))?
|
||||||
|
{
|
||||||
std::mem::drop(process_file(entry).await);
|
std::mem::drop(process_file(entry).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if service_log_dir.exists() {
|
if service_log_dir.exists() {
|
||||||
let mut service_log_read_dir = fs::read_dir(service_log_dir).await?;
|
let mut service_log_read_dir = fs::read_dir(&service_log_dir)
|
||||||
while let Some(entry) = service_log_read_dir.next_entry().await? {
|
.await
|
||||||
|
.with_context(|| format!("failed to read service log directory {}", service_log_dir.display()))?;
|
||||||
|
while let Some(entry) = service_log_read_dir.next_entry().await.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to read next entry in service log directory {}",
|
||||||
|
service_log_dir.display()
|
||||||
|
)
|
||||||
|
})? {
|
||||||
std::mem::drop(process_file(entry).await);
|
std::mem::drop(process_file(entry).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,27 +248,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raise existing sub-floor update intervals, once per install.
|
/// Once per install; the marker is written even when no interval needed raising.
|
||||||
///
|
|
||||||
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
|
|
||||||
/// them, so the marker is written even when nothing needed raising.
|
|
||||||
pub async fn migrate_short_update_intervals() -> Result<()> {
|
pub async fn migrate_short_update_intervals() -> Result<()> {
|
||||||
let marker = dirs::update_interval_migrated_path()?;
|
let marker = dirs::update_interval_migrated_path()?;
|
||||||
if fs::try_exists(&marker).await? {
|
if fs::try_exists(&marker).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same order as every other writer holding it (lock, then draft permit): save_file runs
|
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
|
||||||
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
|
|
||||||
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
||||||
|
|
||||||
let min = constants::profile::MIN_UPDATE_INTERVAL;
|
let min = constants::profile::MIN_UPDATE_INTERVAL;
|
||||||
let raised = Config::profiles()
|
let raised = Config::profiles()
|
||||||
.await
|
.await
|
||||||
.with_data_modify(|mut profiles: IProfiles| async move {
|
.with_data_modify(|mut profiles: IProfiles| async move {
|
||||||
// `IProfiles::new` normalises items to Some when it read the file, and returns a
|
// A failed profiles load (bare default) must not burn the one-shot marker.
|
||||||
// bare default() when it failed. A failed load also raises zero — without this the
|
|
||||||
// marker would burn the one shot on profiles nobody managed to load.
|
|
||||||
if profiles.items.is_none() {
|
if profiles.items.is_none() {
|
||||||
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
|
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
|
||||||
}
|
}
|
||||||
@@ -370,14 +384,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
|
|||||||
"nameserver-policy".into(),
|
"nameserver-policy".into(),
|
||||||
Value::Mapping(serde_yaml_ng::Mapping::new()),
|
Value::Mapping(serde_yaml_ng::Mapping::new()),
|
||||||
),
|
),
|
||||||
(
|
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
|
||||||
"proxy-server-nameserver".into(),
|
|
||||||
Value::Sequence(vec![
|
|
||||||
Value::String("https://doh.pub/dns-query".into()),
|
|
||||||
Value::String("https://dns.alidns.com/dns-query".into()),
|
|
||||||
Value::String("tls://223.5.5.5".into()),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
("direct-nameserver".into(), Value::Sequence(vec![])),
|
("direct-nameserver".into(), Value::Sequence(vec![])),
|
||||||
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
|
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
|
||||||
(
|
(
|
||||||
@@ -509,7 +516,7 @@ pub async fn init_resources() -> Result<()> {
|
|||||||
std::mem::drop(fs::create_dir_all(&res_dir).await);
|
std::mem::drop(fs::create_dir_all(&res_dir).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
|
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
|
||||||
|
|
||||||
for file in file_list.iter() {
|
for file in file_list.iter() {
|
||||||
let src_path = res_dir.join(file);
|
let src_path = res_dir.join(file);
|
||||||
@@ -637,10 +644,9 @@ async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) {
|
|||||||
logging!(
|
logging!(
|
||||||
error,
|
error,
|
||||||
Type::Setup,
|
Type::Setup,
|
||||||
"failed to copy resources '{}' to '{:?}', {}",
|
"failed to copy resource {} to {}: {err}",
|
||||||
file,
|
src.display(),
|
||||||
dest,
|
dest.display()
|
||||||
err
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ pub enum NotificationEvent<'a> {
|
|||||||
LightweightModeEntered,
|
LightweightModeEntered,
|
||||||
ProfilesReactivated,
|
ProfilesReactivated,
|
||||||
AppQuit,
|
AppQuit,
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
AppHidden,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether no app window can currently show the failure.
|
/// Whether no app window can currently show the failure.
|
||||||
@@ -95,11 +93,5 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
|
|||||||
let body = clash_verge_i18n::t!("notifications.appQuit.body");
|
let body = clash_verge_i18n::t!("notifications.appQuit.body");
|
||||||
notify(title, body);
|
notify(title, body);
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
NotificationEvent::AppHidden => {
|
|
||||||
let title = clash_verge_i18n::t!("notifications.appHidden.title");
|
|
||||||
let body = clash_verge_i18n::t!("notifications.appHidden.body");
|
|
||||||
notify(title, body);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use anyhow::Context as _;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -9,7 +10,7 @@ const DNS_STATE_FILE: &str = ".original_dns.txt";
|
|||||||
fn dns_state_dir() -> anyhow::Result<PathBuf> {
|
fn dns_state_dir() -> anyhow::Result<PathBuf> {
|
||||||
// The DNS scripts persist .original_dns.txt relative to their working directory.
|
// The DNS scripts persist .original_dns.txt relative to their working directory.
|
||||||
let dir = crate::utils::dirs::app_home_dir()?;
|
let dir = crate::utils::dirs::app_home_dir()?;
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +38,14 @@ pub async fn set_public_dns(dns_server: String) {
|
|||||||
};
|
};
|
||||||
let script = resource_dir.join("set_dns.sh");
|
let script = resource_dir.join("set_dns.sh");
|
||||||
if !script.exists() {
|
if !script.exists() {
|
||||||
logging!(error, Type::Config, "set_dns.sh not found");
|
logging!(error, Type::Config, "DNS script not found: {}", script.display());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let script = script.to_string_lossy().into_owned();
|
let script = script.to_string_lossy().into_owned();
|
||||||
let state_dir = match dns_state_dir() {
|
let state_dir = match dns_state_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
|
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -85,14 +86,14 @@ pub async fn restore_public_dns() {
|
|||||||
};
|
};
|
||||||
let script = resource_dir.join("unset_dns.sh");
|
let script = resource_dir.join("unset_dns.sh");
|
||||||
if !script.exists() {
|
if !script.exists() {
|
||||||
logging!(error, Type::Config, "unset_dns.sh not found");
|
logging!(error, Type::Config, "DNS script not found: {}", script.display());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let script = script.to_string_lossy().into_owned();
|
let script = script.to_string_lossy().into_owned();
|
||||||
let state_dir = match dns_state_dir() {
|
let state_dir = match dns_state_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
|
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::{
|
|||||||
handle::Handle,
|
handle::Handle,
|
||||||
hotkey::Hotkey,
|
hotkey::Hotkey,
|
||||||
logger,
|
logger,
|
||||||
|
manager::CoreFailure,
|
||||||
service::{SERVICE_MANAGER, ServiceManager},
|
service::{SERVICE_MANAGER, ServiceManager},
|
||||||
tray::Tray,
|
tray::Tray,
|
||||||
},
|
},
|
||||||
@@ -221,6 +222,8 @@ async fn init_core_manager() -> bool {
|
|||||||
Ok(initialized) => initialized,
|
Ok(initialized) => initialized,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
|
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
|
||||||
|
CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}")));
|
||||||
|
Handle::notice_message("core_start::error", "");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::utils::dirs::{self, PathBufExec as _};
|
use crate::utils::dirs::{self, PathBufExec as _};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::os::windows::process::CommandExt as _;
|
use std::os::windows::process::CommandExt as _;
|
||||||
@@ -100,8 +100,14 @@ async fn cleanup_legacy_shortcuts() -> Result<()> {
|
|||||||
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
|
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
|
||||||
let new_shortcut = startup_dir.join("Clash Verge.lnk");
|
let new_shortcut = startup_dir.join("Clash Verge.lnk");
|
||||||
|
|
||||||
old_shortcut.remove_if_exists().await?;
|
old_shortcut
|
||||||
new_shortcut.remove_if_exists().await?;
|
.remove_if_exists()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to remove startup shortcut {}", old_shortcut.display()))?;
|
||||||
|
new_shortcut
|
||||||
|
.remove_if_exists()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to remove startup shortcut {}", new_shortcut.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +333,7 @@ pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
|
|||||||
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
|
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
|
||||||
|
|
||||||
if let Err(err) = cleanup_legacy_shortcuts().await {
|
if let Err(err) = cleanup_legacy_shortcuts().await {
|
||||||
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
|
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_enable {
|
if is_enable {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub(crate) fn report_error(error: &anyhow::Error) {
|
|||||||
eprintln!("[clash-verge] startup failed: {detail}");
|
eprintln!("[clash-verge] startup failed: {detail}");
|
||||||
|
|
||||||
let log_result = startup_log_path().and_then(|path| {
|
let log_result = startup_log_path().and_then(|path| {
|
||||||
append_error(&path, &detail)?;
|
append_error(&path, &detail).with_context(|| format!("startup log {}", path.display()))?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
});
|
});
|
||||||
let message = match log_result {
|
let message = match log_result {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.5.4",
|
"version": "2.5.7",
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
},
|
},
|
||||||
"rpm": {
|
"rpm": {
|
||||||
"depends": ["openssl", "libayatana-appindicator-gtk3"],
|
"depends": ["openssl", "libayatana-appindicator-gtk3"],
|
||||||
|
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
|
||||||
"desktopTemplate": "./packages/linux/clash-verge.desktop",
|
"desktopTemplate": "./packages/linux/clash-verge.desktop",
|
||||||
"provides": ["clash-verge"],
|
"provides": ["clash-verge"],
|
||||||
"conflicts": ["clash-verge"],
|
"conflicts": ["clash-verge"],
|
||||||
"obsoletes": ["clash-verge"],
|
"obsoletes": ["clash-verge"],
|
||||||
"postInstallScript": "./packages/linux/post-install.sh",
|
"postInstallScript": "./packages/linux/post-install-rpm.sh",
|
||||||
"preRemoveScript": "./packages/linux/pre-remove.sh"
|
"preRemoveScript": "./packages/linux/pre-remove.sh"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,12 +13,24 @@ interface SortableItemProps {
|
|||||||
index: number
|
index: number
|
||||||
group?: string
|
group?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
|
||||||
|
measureElement?: (element: HTMLDivElement | null) => void
|
||||||
|
dataIndex?: number
|
||||||
style?: CSSProperties
|
style?: CSSProperties
|
||||||
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
|
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SortableItem = (props: SortableItemProps) => {
|
export const SortableItem = (props: SortableItemProps) => {
|
||||||
const { id, index, group, disabled, children, style } = props
|
const {
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
group,
|
||||||
|
disabled,
|
||||||
|
measureElement,
|
||||||
|
dataIndex,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
} = props
|
||||||
const { ref, handleRef, sortable, isDragging } = useSortable({
|
const { ref, handleRef, sortable, isDragging } = useSortable({
|
||||||
id,
|
id,
|
||||||
index,
|
index,
|
||||||
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
|
|||||||
(element: HTMLDivElement | null) => {
|
(element: HTMLDivElement | null) => {
|
||||||
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
|
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
|
||||||
ref(element)
|
ref(element)
|
||||||
|
measureElement?.(element)
|
||||||
},
|
},
|
||||||
[handleRef, ref],
|
[handleRef, ref, measureElement],
|
||||||
)
|
)
|
||||||
|
|
||||||
const mergedStyle: CSSProperties = {
|
const mergedStyle: CSSProperties = {
|
||||||
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setRef} style={mergedStyle}>
|
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const formatConnectionTraffic = (value?: number) => {
|
|||||||
? 0
|
? 0
|
||||||
: Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1)
|
: Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1)
|
||||||
const data = value / 1024 ** exp
|
const data = value / 1024 ** exp
|
||||||
const text = data >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
const text = Math.round(data) >= 1000 ? data.toFixed(0) : data.toPrecision(3)
|
||||||
return `${text} ${TRAFFIC_UNITS[exp]}`
|
return `${text} ${TRAFFIC_UNITS[exp]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @eslint-react/set-state-in-effect */
|
/* eslint-disable @eslint-react/set-state-in-effect */
|
||||||
import {
|
import {
|
||||||
AccessTimeRounded,
|
AccessTimeRounded,
|
||||||
|
ArrowDropDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
NetworkCheckRounded,
|
NetworkCheckRounded,
|
||||||
WifiOff as SignalError,
|
WifiOff as SignalError,
|
||||||
@@ -15,25 +16,22 @@ import {
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
ButtonBase,
|
||||||
Chip,
|
Chip,
|
||||||
ClickAwayListener,
|
ClickAwayListener,
|
||||||
FormControl,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
InputLabel,
|
|
||||||
type MenuProps,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
Paper,
|
Paper,
|
||||||
Popper,
|
Popper,
|
||||||
Select,
|
type PopperProps,
|
||||||
type SelectChangeEvent,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
@@ -68,6 +66,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
|
|||||||
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
|
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
|
||||||
const AUTO_CHECK_INITIAL_DELAY_MS = 100
|
const AUTO_CHECK_INITIAL_DELAY_MS = 100
|
||||||
const PROXY_MENU_MAX_HEIGHT = 500
|
const PROXY_MENU_MAX_HEIGHT = 500
|
||||||
|
const PROXY_MENU_VIEWPORT_MARGIN = 8
|
||||||
|
|
||||||
|
const proxyMenuModifiers: PopperProps['modifiers'] = [
|
||||||
|
{
|
||||||
|
name: 'menuSize',
|
||||||
|
enabled: true,
|
||||||
|
phase: 'beforeRead',
|
||||||
|
fn: ({ state }) => {
|
||||||
|
const popper = state.elements.popper
|
||||||
|
const anchor = state.elements.reference.getBoundingClientRect()
|
||||||
|
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
|
||||||
|
// Use the larger side before flip runs so sizing cannot alternate with placement.
|
||||||
|
const availableHeight =
|
||||||
|
Math.max(anchor.top, viewportHeight - anchor.bottom) -
|
||||||
|
PROXY_MENU_VIEWPORT_MARGIN
|
||||||
|
popper.style.width = `${anchor.width}px`
|
||||||
|
popper.style.setProperty(
|
||||||
|
'--proxy-menu-max-height',
|
||||||
|
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
|
||||||
|
)
|
||||||
|
state.rects.popper.width = popper.offsetWidth
|
||||||
|
state.rects.popper.height = popper.offsetHeight
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'showMenu',
|
||||||
|
enabled: true,
|
||||||
|
phase: 'beforeWrite',
|
||||||
|
requires: ['computeStyles'],
|
||||||
|
fn: ({ state }) => {
|
||||||
|
state.styles.popper.visibility = 'visible'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
interface ProxyOption {
|
interface ProxyOption {
|
||||||
memberIndex: number
|
memberIndex: number
|
||||||
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
|
|||||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
member.kind === 'node' ? member.node.recordId : member.ref.name
|
||||||
}`
|
}`
|
||||||
|
|
||||||
const hasPointerCoordinates = (
|
|
||||||
value: unknown,
|
|
||||||
): value is { clientX: number; clientY: number } =>
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'clientX' in value &&
|
|
||||||
typeof value.clientX === 'number' &&
|
|
||||||
'clientY' in value &&
|
|
||||||
typeof value.clientY === 'number'
|
|
||||||
|
|
||||||
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
|
|
||||||
if (!element) return false
|
|
||||||
|
|
||||||
const nativeEvent =
|
|
||||||
typeof event === 'object' && event !== null && 'nativeEvent' in event
|
|
||||||
? event.nativeEvent
|
|
||||||
: event
|
|
||||||
if (!hasPointerCoordinates(nativeEvent)) return false
|
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
return (
|
|
||||||
nativeEvent.clientX >= rect.left &&
|
|
||||||
nativeEvent.clientX <= rect.right &&
|
|
||||||
nativeEvent.clientY >= rect.top &&
|
|
||||||
nativeEvent.clientY <= rect.bottom
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortProxyOptions = (
|
const sortProxyOptions = (
|
||||||
options: ProxyOption[],
|
options: ProxyOption[],
|
||||||
sortType: ProxySortType,
|
sortType: ProxySortType,
|
||||||
@@ -216,58 +220,71 @@ const sortProxyOptions = (
|
|||||||
.map(({ option }) => option)
|
.map(({ option }) => option)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersistentProxySelectProps {
|
interface PersistentSelectProps {
|
||||||
label: string
|
label: string
|
||||||
groupName: string
|
displayValue: React.ReactNode
|
||||||
fixed?: string
|
|
||||||
value: string
|
|
||||||
selectedName: string
|
|
||||||
selectedDelay: number
|
|
||||||
options: ProxyOption[]
|
|
||||||
open: boolean
|
open: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
keepOpenRef: React.RefObject<HTMLElement | null>
|
keepOpenRef: React.RefObject<HTMLElement | null>
|
||||||
onOpen: () => void
|
onOpenChange: (open: boolean) => void
|
||||||
onClose: () => void
|
renderOptions: () => React.ReactNode
|
||||||
onChange: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PersistentProxySelect = ({
|
const PersistentSelect = ({
|
||||||
label,
|
label,
|
||||||
groupName,
|
displayValue,
|
||||||
fixed,
|
|
||||||
value,
|
|
||||||
selectedName,
|
|
||||||
selectedDelay,
|
|
||||||
options,
|
|
||||||
open,
|
open,
|
||||||
disabled,
|
disabled,
|
||||||
keepOpenRef,
|
keepOpenRef,
|
||||||
onOpen,
|
onOpenChange,
|
||||||
onClose,
|
renderOptions,
|
||||||
onChange,
|
}: PersistentSelectProps) => {
|
||||||
}: PersistentProxySelectProps) => {
|
const id = useId()
|
||||||
const anchorRef = useRef<HTMLDivElement>(null)
|
const labelId = `${id}-label`
|
||||||
const listboxId = 'current-proxy-node-listbox'
|
const valueId = `${id}-value`
|
||||||
const labelId = 'proxy-select-label'
|
const listboxId = `${id}-listbox`
|
||||||
const fixedProxyInUsed = selectedName === fixed
|
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const popperOptions = useMemo<PopperProps['popperOptions']>(
|
||||||
|
() => ({
|
||||||
|
onFirstUpdate: () => {
|
||||||
|
const list = listRef.current
|
||||||
|
const paper = list?.parentElement
|
||||||
|
const item =
|
||||||
|
list?.querySelector<HTMLElement>(
|
||||||
|
'[aria-selected="true"]:not([aria-disabled="true"])',
|
||||||
|
) ??
|
||||||
|
list?.querySelector<HTMLElement>(
|
||||||
|
'[role="option"]:not([aria-disabled="true"])',
|
||||||
|
)
|
||||||
|
if (!item || !paper) return
|
||||||
|
|
||||||
|
// Focusing before positioning can scroll the page instead of just the menu.
|
||||||
|
item.focus({ preventScroll: true })
|
||||||
|
paper.scrollTop +=
|
||||||
|
item.getBoundingClientRect().top -
|
||||||
|
paper.getBoundingClientRect().top -
|
||||||
|
(paper.clientHeight - item.offsetHeight) / 2
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
||||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
onClose()
|
onOpenChange(false)
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
onClose()
|
onOpenChange(false)
|
||||||
anchorRef.current
|
anchorRef.current?.focus({ preventScroll: true })
|
||||||
?.querySelector<HTMLElement>('[role="combobox"]')
|
|
||||||
?.focus()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', closeFromKeyboard)
|
document.addEventListener('keydown', closeFromKeyboard)
|
||||||
return () => document.removeEventListener('keydown', closeFromKeyboard)
|
return () => document.removeEventListener('keydown', closeFromKeyboard)
|
||||||
}, [onClose, open])
|
}, [onOpenChange, open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClickAwayListener
|
<ClickAwayListener
|
||||||
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
|
|||||||
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
|
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onClose()
|
onOpenChange(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
|
<Box
|
||||||
<InputLabel id={labelId}>{label}</InputLabel>
|
sx={{
|
||||||
<Select
|
position: 'relative',
|
||||||
labelId={labelId}
|
borderRadius: 1,
|
||||||
value={value}
|
'& fieldset': {
|
||||||
open={false}
|
borderColor: (theme) =>
|
||||||
onOpen={onOpen}
|
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
|
||||||
label={label}
|
},
|
||||||
|
...(!disabled && {
|
||||||
|
'&:hover fieldset': { borderColor: 'text.primary' },
|
||||||
|
'&:focus-within fieldset': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
'&:focus-within legend': { color: 'primary.main' },
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ButtonBase
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-labelledby={`${labelId} ${valueId}`}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={open ? listboxId : undefined}
|
||||||
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
renderValue={() => (
|
onClick={() => onOpenChange(!open)}
|
||||||
<Box
|
onKeyDown={(event) => {
|
||||||
sx={{
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
display: 'flex',
|
event.preventDefault()
|
||||||
justifyContent: 'space-between',
|
onOpenChange(true)
|
||||||
alignItems: 'center',
|
}
|
||||||
}}
|
}}
|
||||||
>
|
sx={{
|
||||||
<Typography noWrap>{selectedName}</Typography>
|
width: '100%',
|
||||||
<Chip
|
height: 40,
|
||||||
size="small"
|
px: 1.75,
|
||||||
label={delayManager.formatDelay(selectedDelay)}
|
borderRadius: 'inherit',
|
||||||
color={convertDelayColor(selectedDelay)}
|
justifyContent: 'space-between',
|
||||||
/>
|
textAlign: 'left',
|
||||||
{fixedProxyInUsed && (
|
color: disabled ? 'text.disabled' : 'text.primary',
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
fontSize: '12px',
|
|
||||||
top: '-3px',
|
|
||||||
right: '25px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📌
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
SelectDisplayProps={{
|
|
||||||
'aria-controls': open ? listboxId : undefined,
|
|
||||||
'aria-expanded': open ? 'true' : 'false',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value={value}>{selectedName}</MenuItem>
|
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
|
||||||
</Select>
|
{displayValue}
|
||||||
</FormControl>
|
</Box>
|
||||||
|
<ArrowDropDown
|
||||||
|
sx={{
|
||||||
|
ml: 1,
|
||||||
|
color: disabled ? 'action.disabled' : 'action.active',
|
||||||
|
transform: open ? 'rotate(180deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ButtonBase>
|
||||||
|
<Box
|
||||||
|
component="fieldset"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-5px 0 0',
|
||||||
|
m: 0,
|
||||||
|
px: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
border: '1px solid',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="legend"
|
||||||
|
id={labelId}
|
||||||
|
sx={{
|
||||||
|
px: 0.5,
|
||||||
|
typography: 'caption',
|
||||||
|
lineHeight: '11px',
|
||||||
|
color: disabled ? 'text.disabled' : 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Popper
|
<Popper
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorRef.current}
|
anchorEl={anchorRef.current}
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
modifiers={proxyMenuModifiers}
|
||||||
|
popperOptions={popperOptions}
|
||||||
sx={{
|
sx={{
|
||||||
width: anchorRef.current?.clientWidth,
|
visibility: 'hidden',
|
||||||
zIndex: (theme) => theme.zIndex.modal,
|
zIndex: (theme) => theme.zIndex.modal,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={8}
|
elevation={8}
|
||||||
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
|
sx={{
|
||||||
|
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuList
|
<MenuList
|
||||||
|
ref={listRef}
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
autoFocusItem
|
|
||||||
variant="selectedMenu"
|
variant="selectedMenu"
|
||||||
>
|
>
|
||||||
{options.map((option) => {
|
{open && renderOptions()}
|
||||||
const selected = option.value === value
|
|
||||||
const isFixed = option.name === fixed
|
|
||||||
const delay = option.disabled
|
|
||||||
? -1
|
|
||||||
: delayManager.getDelayFix(option.member, groupName)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
key={option.value}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
selected={selected}
|
|
||||||
disabled={option.disabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (!option.disabled) onChange(option.value)
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
pr: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isFixed && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
fontSize: '12px',
|
|
||||||
top: '-5px',
|
|
||||||
right: '5px',
|
|
||||||
...(!fixedProxyInUsed && {
|
|
||||||
filter: 'grayscale(1)',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📌
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
|
||||||
{option.name}
|
|
||||||
</Typography>
|
|
||||||
{!option.disabled && (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={delayManager.formatDelay(delay)}
|
|
||||||
color={convertDelayColor(delay)}
|
|
||||||
sx={{
|
|
||||||
minWidth: '60px',
|
|
||||||
height: '22px',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Popper>
|
</Popper>
|
||||||
@@ -413,6 +415,124 @@ const PersistentProxySelect = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistentProxySelectProps
|
||||||
|
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
|
||||||
|
value: string
|
||||||
|
selectedName: string
|
||||||
|
groupName: string
|
||||||
|
fixed?: string
|
||||||
|
selectedDelay: number
|
||||||
|
options: ProxyOption[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersistentProxySelect = ({
|
||||||
|
groupName,
|
||||||
|
fixed,
|
||||||
|
selectedName,
|
||||||
|
selectedDelay,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: PersistentProxySelectProps) => {
|
||||||
|
const fixedProxyInUsed = selectedName === fixed
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PersistentSelect
|
||||||
|
{...props}
|
||||||
|
displayValue={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography noWrap>{selectedName}</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={delayManager.formatDelay(selectedDelay)}
|
||||||
|
color={convertDelayColor(selectedDelay)}
|
||||||
|
/>
|
||||||
|
{fixedProxyInUsed && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
fontSize: '12px',
|
||||||
|
top: '-3px',
|
||||||
|
right: '25px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
renderOptions={() =>
|
||||||
|
options.map((option) => {
|
||||||
|
const selected = option.value === value
|
||||||
|
const isFixed = option.name === fixed
|
||||||
|
const delay = option.disabled
|
||||||
|
? -1
|
||||||
|
: delayManager.getDelayFix(option.member, groupName)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={option.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
selected={selected}
|
||||||
|
disabled={option.disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!option.disabled) onChange(option.value)
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
pr: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFixed && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
fontSize: '12px',
|
||||||
|
top: '-5px',
|
||||||
|
right: '5px',
|
||||||
|
...(!fixedProxyInUsed && {
|
||||||
|
filter: 'grayscale(1)',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📌
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||||
|
{option.name}
|
||||||
|
</Typography>
|
||||||
|
{!option.disabled && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={delayManager.formatDelay(delay)}
|
||||||
|
color={convertDelayColor(delay)}
|
||||||
|
sx={{
|
||||||
|
minWidth: '60px',
|
||||||
|
height: '22px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const CurrentProxyCard = () => {
|
export const CurrentProxyCard = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
|
|||||||
|
|
||||||
const [selectedGroupName, setSelectedGroupName] = useState('')
|
const [selectedGroupName, setSelectedGroupName] = useState('')
|
||||||
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenSelect(null)
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
const delayButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
const delays = useGroupDelays(selectedGroupName || null)
|
const delays = useGroupDelays(selectedGroupName || null)
|
||||||
|
|
||||||
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
|
|||||||
latestProxyMemberRef.current = currentOption?.member ?? null
|
latestProxyMemberRef.current = currentOption?.member ?? null
|
||||||
|
|
||||||
const handleGroupChange = useCallback(
|
const handleGroupChange = useCallback(
|
||||||
(event: SelectChangeEvent<string>) => {
|
(newGroupName: string) => {
|
||||||
if (isGlobalMode || isDirectMode) return
|
if (isGlobalMode || isDirectMode) return
|
||||||
const newGroupName = event.target.value
|
|
||||||
setSelectedGroupName(newGroupName)
|
setSelectedGroupName(newGroupName)
|
||||||
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
|
||||||
},
|
},
|
||||||
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
|
|||||||
refreshProxy()
|
refreshProxy()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
|
|
||||||
(event, reason) => {
|
|
||||||
if (
|
|
||||||
reason === 'backdropClick' &&
|
|
||||||
eventHitsElement(event, delayButtonRef.current)
|
|
||||||
) {
|
|
||||||
void handleCheckDelay()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenSelect(null)
|
|
||||||
},
|
|
||||||
[handleCheckDelay],
|
|
||||||
)
|
|
||||||
|
|
||||||
const proxyOptions = useMemo(
|
const proxyOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isDirectMode
|
isDirectMode || openSelect !== 'proxy'
|
||||||
? []
|
? []
|
||||||
: sortProxyOptions(
|
: sortProxyOptions(
|
||||||
unsortedProxyOptions,
|
unsortedProxyOptions,
|
||||||
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
|
|||||||
[
|
[
|
||||||
delays,
|
delays,
|
||||||
isDirectMode,
|
isDirectMode,
|
||||||
|
openSelect,
|
||||||
unsortedProxyOptions,
|
unsortedProxyOptions,
|
||||||
sortType,
|
sortType,
|
||||||
defaultLatencyTimeout,
|
defaultLatencyTimeout,
|
||||||
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl
|
<Box sx={{ mb: 1.5 }}>
|
||||||
fullWidth
|
<PersistentSelect
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
sx={{ mb: 1.5 }}
|
|
||||||
>
|
|
||||||
<InputLabel id="proxy-group-select-label">
|
|
||||||
{t('home.components.currentProxy.labels.group')}
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="proxy-group-select-label"
|
|
||||||
value={selectedGroupName}
|
|
||||||
onChange={handleGroupChange}
|
|
||||||
open={openSelect === 'group'}
|
|
||||||
onOpen={() => setOpenSelect('group')}
|
|
||||||
label={t('home.components.currentProxy.labels.group')}
|
label={t('home.components.currentProxy.labels.group')}
|
||||||
|
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
|
||||||
|
open={openSelect === 'group'}
|
||||||
disabled={isGlobalMode || isDirectMode}
|
disabled={isGlobalMode || isDirectMode}
|
||||||
MenuProps={{ onClose: handleGroupMenuClose }}
|
keepOpenRef={delayButtonRef}
|
||||||
>
|
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
|
||||||
{selectableGroups.map((group) => (
|
renderOptions={() =>
|
||||||
<MenuItem key={group.name} value={group.name}>
|
selectableGroups.map((group) => (
|
||||||
{group.name}
|
<MenuItem
|
||||||
</MenuItem>
|
key={group.name}
|
||||||
))}
|
role="option"
|
||||||
</Select>
|
aria-selected={group.name === selectedGroupName}
|
||||||
</FormControl>
|
selected={group.name === selectedGroupName}
|
||||||
|
onClick={() => handleGroupChange(group.name)}
|
||||||
|
>
|
||||||
|
<Typography noWrap>{group.name}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<PersistentProxySelect
|
<PersistentProxySelect
|
||||||
label={t('home.components.currentProxy.labels.proxy')}
|
label={t('home.components.currentProxy.labels.proxy')}
|
||||||
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
|
|||||||
}
|
}
|
||||||
selectedName={selectedProxyName}
|
selectedName={selectedProxyName}
|
||||||
selectedDelay={currentDelay}
|
selectedDelay={currentDelay}
|
||||||
options={isDirectMode ? [] : proxyOptions}
|
options={proxyOptions}
|
||||||
open={openSelect === 'proxy'}
|
open={openSelect === 'proxy'}
|
||||||
disabled={isDirectMode}
|
disabled={isDirectMode}
|
||||||
keepOpenRef={delayButtonRef}
|
keepOpenRef={delayButtonRef}
|
||||||
onOpen={() => setOpenSelect('proxy')}
|
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
|
||||||
onClose={() => setOpenSelect(null)}
|
|
||||||
onChange={handleProxyChange}
|
onChange={handleProxyChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { useAppRefreshers } from '@/providers/app-data-context'
|
import { useAppRefreshers } from '@/providers/app-data-context'
|
||||||
import { updateProfile } from '@/services/cmds'
|
import { updateProfile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
|
import { isValidUrl } from '@/utils/network'
|
||||||
import { openExternalUrl } from '@/utils/open-external-url'
|
import { openExternalUrl } from '@/utils/open-external-url'
|
||||||
import parseTraffic from '@/utils/parse-traffic'
|
import parseTraffic from '@/utils/parse-traffic'
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ const round = keyframes`
|
|||||||
// 辅助函数解析URL和过期时间
|
// 辅助函数解析URL和过期时间
|
||||||
const parseUrl = (url?: string) => {
|
const parseUrl = (url?: string) => {
|
||||||
if (!url) return '-'
|
if (!url) return '-'
|
||||||
if (url.startsWith('http')) return new URL(url).host
|
if (isValidUrl(url)) return new URL(url).host
|
||||||
return 'local'
|
return 'local'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AdminPanelSettingsOutlined,
|
AdminPanelSettingsOutlined,
|
||||||
DnsOutlined,
|
DnsOutlined,
|
||||||
ExtensionOutlined,
|
ExtensionOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
|
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
|
||||||
import { useLockFn } from 'ahooks'
|
import { useLockFn } from 'ahooks'
|
||||||
@@ -29,7 +30,8 @@ export const SystemInfoCard = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { verge, patchVerge } = useVerge()
|
const { verge, patchVerge } = useVerge()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState()
|
const { runningMode, isAdminMode, isSidecarMode, mutateSystemState } =
|
||||||
|
useSystemState()
|
||||||
const { installServiceAndRestartCore } = useServiceInstaller()
|
const { installServiceAndRestartCore } = useServiceInstaller()
|
||||||
|
|
||||||
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
|
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
|
||||||
@@ -108,7 +110,8 @@ export const SystemInfoCard = () => {
|
|||||||
goToSettings()
|
goToSettings()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showNotice.error(err)
|
console.warn('update check failed:', err)
|
||||||
|
showNotice.warning(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -134,6 +137,14 @@ export const SystemInfoCard = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getModeIcon = () => {
|
const getModeIcon = () => {
|
||||||
|
if (runningMode === 'NotRunning') {
|
||||||
|
return (
|
||||||
|
<PauseCircleOutlined
|
||||||
|
sx={{ color: 'text.secondary', fontSize: 16 }}
|
||||||
|
titleAccess={t('home.components.systemInfo.badges.notRunning')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
if (!isSidecarMode) {
|
if (!isSidecarMode) {
|
||||||
return (
|
return (
|
||||||
@@ -173,6 +184,9 @@ export const SystemInfoCard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getModeText = () => {
|
const getModeText = () => {
|
||||||
|
if (runningMode === 'NotRunning') {
|
||||||
|
return t('home.components.systemInfo.badges.notRunning')
|
||||||
|
}
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
if (!isSidecarMode) {
|
if (!isSidecarMode) {
|
||||||
return t('home.components.systemInfo.badges.adminServiceMode')
|
return t('home.components.systemInfo.badges.adminServiceMode')
|
||||||
|
|||||||
@@ -100,13 +100,18 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
|
|||||||
const item = navItemMap.get(path)
|
const item = navItemMap.get(path)
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
|
// A registered sortable, even a disabled one, gets the drag-and-drop ARIA
|
||||||
|
// attributes, so screen readers announce every item as a disabled draggable.
|
||||||
|
if (!menuUnlocked) {
|
||||||
|
return (
|
||||||
|
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
|
||||||
|
{t(item.label)}
|
||||||
|
</LayoutItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableItem
|
<SortableItem key={item.path} id={item.path} index={index}>
|
||||||
key={item.path}
|
|
||||||
id={item.path}
|
|
||||||
index={index}
|
|
||||||
disabled={!menuUnlocked}
|
|
||||||
>
|
|
||||||
{(sortable) => (
|
{(sortable) => (
|
||||||
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
|
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
|
||||||
{t(item.label)}
|
{t(item.label)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Snackbar,
|
Snackbar,
|
||||||
Alert,
|
Alert,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Link,
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
type SnackbarOrigin,
|
type SnackbarOrigin,
|
||||||
@@ -18,6 +19,14 @@ import {
|
|||||||
showNotice,
|
showNotice,
|
||||||
} from '@/services/notice-service'
|
} from '@/services/notice-service'
|
||||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||||
|
import { openExternalUrl } from '@/utils/open-external-url'
|
||||||
|
|
||||||
|
const SERVICE_PERMISSION_NOTICE =
|
||||||
|
'settings.feedback.notifications.clashService.permissionFallback'
|
||||||
|
const SERVICE_PERMISSION_GUIDE =
|
||||||
|
'https://clash-verge-rev.github.io/faq/windows.html#service-core-permissions'
|
||||||
|
const SERVICE_OWNER_NOTICE =
|
||||||
|
'settings.feedback.notifications.clashService.appDataNotOwned'
|
||||||
|
|
||||||
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
|
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
|
||||||
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
|
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
|
||||||
@@ -59,6 +68,55 @@ const resolveNoticeMessage = (
|
|||||||
const i18n = notice.i18n
|
const i18n = notice.i18n
|
||||||
if (!i18n) return bound(notice.message)
|
if (!i18n) return bound(notice.message)
|
||||||
|
|
||||||
|
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t(SERVICE_PERMISSION_NOTICE, {
|
||||||
|
reason: t(
|
||||||
|
'settings.feedback.notifications.clashService.permissionRejectedReason',
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<Link
|
||||||
|
href={SERVICE_PERMISSION_GUIDE}
|
||||||
|
color="inherit"
|
||||||
|
underline="always"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
|
||||||
|
showNotice.error,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
'settings.feedback.notifications.clashService.permissionRepairGuide',
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i18n.key === SERVICE_OWNER_NOTICE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t(SERVICE_OWNER_NOTICE)}
|
||||||
|
<Box
|
||||||
|
component="code"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
mt: 1,
|
||||||
|
userSelect: 'text',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(i18n.params?.command ?? '')}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const source = (i18n.params ?? {}) as Record<string, unknown>
|
const source = (i18n.params ?? {}) as Record<string, unknown>
|
||||||
// Bound both parameters and their final interpolation.
|
// Bound both parameters and their final interpolation.
|
||||||
const params =
|
const params =
|
||||||
@@ -132,6 +190,12 @@ const resolveNoticeCopyText = (
|
|||||||
notice: NoticeItem,
|
notice: NoticeItem,
|
||||||
t: TranslationFn,
|
t: TranslationFn,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
|
if (notice.i18n?.key === SERVICE_PERMISSION_NOTICE) {
|
||||||
|
return extractNoticeCopyText(notice.i18n.params?.reason)
|
||||||
|
}
|
||||||
|
if (notice.i18n?.key === SERVICE_OWNER_NOTICE) {
|
||||||
|
return extractNoticeCopyText(notice.i18n.params?.command)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
|
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
|
||||||
notice.i18n?.key === 'shared.feedback.notices.raw'
|
notice.i18n?.key === 'shared.feedback.notices.raw'
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRuntimeState,
|
||||||
|
installService,
|
||||||
|
restartCore,
|
||||||
|
type RunState,
|
||||||
|
} from '@/services/cmds'
|
||||||
|
import { showNotice } from '@/services/notice-service'
|
||||||
|
import { useQuery } from '@/services/query-client'
|
||||||
|
|
||||||
|
import { ServiceMigrationDialog } from './service-migration-dialog'
|
||||||
|
|
||||||
|
vi.mock('react', () => ({ useState: vi.fn() }))
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}))
|
||||||
|
vi.mock('@mui/material', () => ({ Alert: 'div' }))
|
||||||
|
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
|
||||||
|
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
|
||||||
|
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||||
|
vi.mock('@/services/cmds', () => ({
|
||||||
|
getRuntimeState: vi.fn(),
|
||||||
|
installService: vi.fn(),
|
||||||
|
restartCore: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/services/notice-service', () => ({
|
||||||
|
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||||
|
}))
|
||||||
|
vi.mock('@/services/query-client', () => ({
|
||||||
|
useQuery: vi.fn(),
|
||||||
|
setCacheData: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useState)
|
||||||
|
.mockReturnValueOnce([false, vi.fn()])
|
||||||
|
.mockReturnValueOnce([false, vi.fn()])
|
||||||
|
.mockReturnValueOnce([true, vi.fn()])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ service: 'ready', mode: 'NotRunning' },
|
||||||
|
{ service: 'unknown', mode: 'NotRunning' },
|
||||||
|
{ service: 'unavailable', mode: 'Service' },
|
||||||
|
{ service: 'unavailable', mode: 'NotRunning', opInFlight: true },
|
||||||
|
{ service: 'unavailable', mode: 'NotRunning', pendingAction: 'reinstall' },
|
||||||
|
] satisfies Partial<RunState>[])(
|
||||||
|
'does not offer Sidecar continuation from %j',
|
||||||
|
(state) => {
|
||||||
|
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||||
|
typeof useQuery
|
||||||
|
>)
|
||||||
|
const dialog = ServiceMigrationDialog()
|
||||||
|
expect(dialog.props.open).toBe(true)
|
||||||
|
expect(dialog.props.disableCancel).toBe(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
|
||||||
|
vi.mocked(useQuery).mockReturnValue({
|
||||||
|
data: { service: 'notInstalled', mode: 'NotRunning' },
|
||||||
|
} as ReturnType<typeof useQuery>)
|
||||||
|
const reason = 'core path C:\\ is writable by Everyone'
|
||||||
|
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
|
||||||
|
vi.mocked(getRuntimeState).mockResolvedValue({
|
||||||
|
service: 'unavailable',
|
||||||
|
mode: 'Sidecar',
|
||||||
|
sidecarAllowed: true,
|
||||||
|
serviceNeedsAttention: false,
|
||||||
|
} as RunState)
|
||||||
|
|
||||||
|
const dialog = ServiceMigrationDialog()
|
||||||
|
dialog.props.onOk()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(showNotice.warning).toHaveBeenCalledWith(
|
||||||
|
'settings.feedback.notifications.clashService.permissionFallback',
|
||||||
|
{ reason },
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(restartCore).not.toHaveBeenCalled()
|
||||||
|
expect(showNotice.error).not.toHaveBeenCalled()
|
||||||
|
expect(showNotice.success).not.toHaveBeenCalled()
|
||||||
|
expect(vi.mocked(useState).mock.results[2].value[1]).toHaveBeenLastCalledWith(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ service: 'notInstalled', mode: 'NotRunning' },
|
||||||
|
{ service: 'versionMismatch', mode: 'NotRunning' },
|
||||||
|
{ service: 'unavailable', mode: 'NotRunning' },
|
||||||
|
{ service: 'notInstalled', mode: 'Sidecar', pendingAction: 'install' },
|
||||||
|
] satisfies Partial<RunState>[])(
|
||||||
|
'offers Sidecar continuation from %j',
|
||||||
|
(state) => {
|
||||||
|
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||||
|
typeof useQuery
|
||||||
|
>)
|
||||||
|
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
repairService,
|
repairService,
|
||||||
restartCore,
|
restartCore,
|
||||||
type RunState,
|
type RunState,
|
||||||
|
type ServiceInstallOutcome,
|
||||||
} from '@/services/cmds'
|
} from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
import { setCacheData, useQuery } from '@/services/query-client'
|
import { setCacheData, useQuery } from '@/services/query-client'
|
||||||
@@ -44,7 +45,24 @@ export const ServiceMigrationDialog = () => {
|
|||||||
? 'install'
|
? 'install'
|
||||||
: 'reinstall'
|
: 'reinstall'
|
||||||
const open = loading || workflowIncomplete || needsDecision
|
const open = loading || workflowIncomplete || needsDecision
|
||||||
const showCheckingMessage = loading || !needsDecision
|
const checking =
|
||||||
|
loading ||
|
||||||
|
!runState ||
|
||||||
|
runState.opInFlight ||
|
||||||
|
runState.service === 'unknown'
|
||||||
|
const showCheckingMessage = checking || !needsDecision
|
||||||
|
const canContinue = Boolean(
|
||||||
|
runState &&
|
||||||
|
!stateRefreshFailed &&
|
||||||
|
(runState.pendingAction === 'install'
|
||||||
|
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
|
||||||
|
: !runState.pendingAction &&
|
||||||
|
!runState.sidecarAllowed &&
|
||||||
|
runState.mode === 'NotRunning' &&
|
||||||
|
(runState.service === 'notInstalled' ||
|
||||||
|
runState.service === 'versionMismatch' ||
|
||||||
|
runState.service === 'unavailable')),
|
||||||
|
)
|
||||||
|
|
||||||
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
|
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
|
||||||
const refreshRunState = async () => {
|
const refreshRunState = async () => {
|
||||||
@@ -62,16 +80,15 @@ export const ServiceMigrationDialog = () => {
|
|||||||
const handleServiceAction = async () => {
|
const handleServiceAction = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setWorkflowIncomplete(true)
|
setWorkflowIncomplete(true)
|
||||||
let actionSucceeded = false
|
let outcome: ServiceInstallOutcome | undefined
|
||||||
try {
|
try {
|
||||||
if (remedy === 'install') {
|
if (remedy === 'install') {
|
||||||
await installService()
|
outcome = await installService()
|
||||||
} else if (remedy === 'repair') {
|
} else if (remedy === 'repair') {
|
||||||
await repairService()
|
outcome = await repairService()
|
||||||
} else {
|
} else {
|
||||||
await reinstallService()
|
outcome = await reinstallService()
|
||||||
}
|
}
|
||||||
actionSucceeded = true
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotice.error(
|
showNotice.error(
|
||||||
'layout.components.serviceMigration.errors.actionFailed',
|
'layout.components.serviceMigration.errors.actionFailed',
|
||||||
@@ -89,10 +106,20 @@ export const ServiceMigrationDialog = () => {
|
|||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!actionSucceeded || !initialRefreshSucceeded) {
|
if (!outcome || !initialRefreshSucceeded) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (outcome.status === 'sidecar') {
|
||||||
|
setWorkflowIncomplete(false)
|
||||||
|
setLoading(false)
|
||||||
|
showNotice.warning(
|
||||||
|
'settings.feedback.notifications.clashService.permissionFallback',
|
||||||
|
{ reason: outcome.reason },
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let restartSucceeded = false
|
let restartSucceeded = false
|
||||||
try {
|
try {
|
||||||
@@ -165,8 +192,8 @@ export const ServiceMigrationDialog = () => {
|
|||||||
: 'layout.components.serviceMigration.reinstall',
|
: 'layout.components.serviceMigration.reinstall',
|
||||||
)}
|
)}
|
||||||
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
|
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
|
||||||
disableOk={loading}
|
disableOk={checking}
|
||||||
disableCancel={loading}
|
disableCancel={checking || !canContinue}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onOk={() => void handleServiceAction()}
|
onOk={() => void handleServiceAction()}
|
||||||
onCancel={() => void handleContinue()}
|
onCancel={() => void handleContinue()}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
getRuntimeState,
|
getRuntimeState,
|
||||||
installService,
|
installService,
|
||||||
patchVergeConfig,
|
patchVergeConfig,
|
||||||
|
reinstallService,
|
||||||
restartCore,
|
restartCore,
|
||||||
type FailedOperation,
|
type FailedOperation,
|
||||||
type PendingFailure,
|
type PendingFailure,
|
||||||
|
type ServiceInstallOutcome,
|
||||||
} from '@/services/cmds'
|
} from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
import {
|
import {
|
||||||
@@ -20,23 +22,31 @@ import {
|
|||||||
type ServiceRequest,
|
type ServiceRequest,
|
||||||
type ServiceRequestReason,
|
type ServiceRequestReason,
|
||||||
} from '@/services/service-request'
|
} from '@/services/service-request'
|
||||||
|
import getSystem from '@/utils/get-system'
|
||||||
|
|
||||||
type Remedy = 'installAndRestart' | 'restartOnly'
|
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
|
||||||
|
|
||||||
const remedyFor = (reason: ServiceRequestReason): Remedy =>
|
const remedyFor = (reason: ServiceRequestReason): Remedy =>
|
||||||
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
|
reason === 'serviceLocationRefused'
|
||||||
|
? 'reinstallAndRestart'
|
||||||
|
: reason === 'sysproxySidecarReady'
|
||||||
|
? 'restartOnly'
|
||||||
|
: 'installAndRestart'
|
||||||
|
|
||||||
const EXPLANATION = {
|
const EXPLANATION = {
|
||||||
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
|
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
|
||||||
sysproxySidecarReady:
|
sysproxySidecarReady:
|
||||||
'layout.components.sysproxyPrivilege.serviceReadyMessage',
|
'layout.components.sysproxyPrivilege.serviceReadyMessage',
|
||||||
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
|
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
|
||||||
|
serviceLocationRefused:
|
||||||
|
'layout.components.serviceMigration.locationRefusedMessage',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const TITLE = {
|
const TITLE = {
|
||||||
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
|
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
|
||||||
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
|
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
|
||||||
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
|
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
|
||||||
|
serviceLocationRefused: 'layout.components.serviceMigration.repair',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const stateToRestore = (
|
const stateToRestore = (
|
||||||
@@ -69,7 +79,6 @@ const STEP_MESSAGE = {
|
|||||||
applying: 'layout.components.sysproxyPrivilege.applying',
|
applying: 'layout.components.sysproxyPrivilege.applying',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Guide recovery from a refused system-proxy write. */
|
|
||||||
export const SysproxyPrivilegeDialog = () => {
|
export const SysproxyPrivilegeDialog = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { failure, dismiss } = useDialogFailure()
|
const { failure, dismiss } = useDialogFailure()
|
||||||
@@ -91,26 +100,48 @@ export const SysproxyPrivilegeDialog = () => {
|
|||||||
|
|
||||||
const handleFix = async () => {
|
const handleFix = async () => {
|
||||||
try {
|
try {
|
||||||
if (remedy === 'installAndRestart') {
|
let outcome: ServiceInstallOutcome | undefined
|
||||||
|
if (remedy === 'reinstallAndRestart') {
|
||||||
setStep('installing')
|
setStep('installing')
|
||||||
await installService()
|
outcome = await reinstallService()
|
||||||
|
} else if (remedy === 'installAndRestart') {
|
||||||
|
setStep('installing')
|
||||||
|
outcome = await installService()
|
||||||
|
}
|
||||||
|
if (outcome?.status === 'sidecar') {
|
||||||
|
showNotice.warning(
|
||||||
|
'settings.feedback.notifications.clashService.permissionFallback',
|
||||||
|
{ reason: outcome.reason },
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
setStep('restarting')
|
setStep('restarting')
|
||||||
await restartCore()
|
await restartCore()
|
||||||
|
|
||||||
// Verify that restart actually moved the core into the service.
|
|
||||||
const runState = await getRuntimeState()
|
const runState = await getRuntimeState()
|
||||||
if (runState.mode === 'Service') {
|
const usingAdminFallback =
|
||||||
// Retry the original request now that the service can apply it.
|
remedy !== 'reinstallAndRestart' &&
|
||||||
|
getSystem() === 'windows' &&
|
||||||
|
runState.mode === 'Sidecar' &&
|
||||||
|
runState.isAdmin &&
|
||||||
|
runState.sidecarAllowed &&
|
||||||
|
runState.service === 'unavailable'
|
||||||
|
if (runState.mode === 'Service' || usingAdminFallback) {
|
||||||
if (restoring !== undefined) {
|
if (restoring !== undefined) {
|
||||||
setStep('applying')
|
setStep('applying')
|
||||||
await patchVergeConfig(restoring)
|
await patchVergeConfig(restoring)
|
||||||
}
|
}
|
||||||
showNotice.success(
|
if (!usingAdminFallback) {
|
||||||
restoring === undefined
|
showNotice.success(
|
||||||
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
remedy === 'reinstallAndRestart'
|
||||||
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
? 'layout.components.serviceMigration.success'
|
||||||
)
|
: restoring === undefined
|
||||||
|
? 'settings.sections.proxyControl.messages.installedCheckProxy'
|
||||||
|
: 'settings.sections.proxyControl.messages.installedProxyRestored',
|
||||||
|
)
|
||||||
|
}
|
||||||
close()
|
close()
|
||||||
} else {
|
} else {
|
||||||
showNotice.error(
|
showNotice.error(
|
||||||
@@ -129,9 +160,11 @@ export const SysproxyPrivilegeDialog = () => {
|
|||||||
open={Boolean(request)}
|
open={Boolean(request)}
|
||||||
title={t(TITLE[reason])}
|
title={t(TITLE[reason])}
|
||||||
okBtn={t(
|
okBtn={t(
|
||||||
remedy === 'installAndRestart'
|
remedy === 'reinstallAndRestart'
|
||||||
? 'settings.sections.proxyControl.actions.installService'
|
? 'layout.components.serviceMigration.reinstall'
|
||||||
: 'settings.sections.proxyControl.actions.switchToServiceMode',
|
: remedy === 'installAndRestart'
|
||||||
|
? 'settings.sections.proxyControl.actions.installService'
|
||||||
|
: 'settings.sections.proxyControl.actions.switchToServiceMode',
|
||||||
)}
|
)}
|
||||||
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
|
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
|
||||||
// Keep the primary spinner visible; only cancellation is unavailable.
|
// Keep the primary spinner visible; only cancellation is unavailable.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from '@mui/material'
|
import { Button } from '@mui/material'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
import { DialogRef } from '@/components/base'
|
import { DialogRef } from '@/components/base'
|
||||||
import { useUpdate } from '@/hooks/use-update'
|
import { useUpdate } from '@/hooks/use-update'
|
||||||
@@ -13,7 +12,6 @@ interface Props {
|
|||||||
|
|
||||||
export const UpdateButton = (props: Props) => {
|
export const UpdateButton = (props: Props) => {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const { t } = useTranslation()
|
|
||||||
const viewerRef = useRef<DialogRef>(null)
|
const viewerRef = useRef<DialogRef>(null)
|
||||||
|
|
||||||
const { updateInfo } = useUpdate()
|
const { updateInfo } = useUpdate()
|
||||||
@@ -31,7 +29,7 @@ export const UpdateButton = (props: Props) => {
|
|||||||
className={className}
|
className={className}
|
||||||
onClick={() => viewerRef.current?.open()}
|
onClick={() => viewerRef.current?.open()}
|
||||||
>
|
>
|
||||||
{t('shared.actions.new')}
|
New
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type EditorLanguage = 'yaml' | 'javascript' | 'css'
|
|||||||
export interface EditorViewerProps {
|
export interface EditorViewerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
title?: string | ReactNode
|
title?: string | ReactNode
|
||||||
|
description?: ReactNode
|
||||||
value: string
|
value: string
|
||||||
language: EditorLanguage
|
language: EditorLanguage
|
||||||
path: string
|
path: string
|
||||||
@@ -50,6 +51,7 @@ export interface EditorViewerProps {
|
|||||||
export const EditorViewer = ({
|
export const EditorViewer = ({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
value,
|
value,
|
||||||
language,
|
language,
|
||||||
path,
|
path,
|
||||||
@@ -207,6 +209,7 @@ export const EditorViewer = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{description}
|
||||||
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
|
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
|
||||||
<BaseLoadingOverlay isLoading={loading} />
|
<BaseLoadingOverlay isLoading={loading} />
|
||||||
{!loading && (
|
{!loading && (
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Alert, Box, Typography } from '@mui/material'
|
||||||
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const stageHints = {
|
||||||
|
globalMerge: 'profiles.modals.editor.enhance.globalMerge',
|
||||||
|
globalScript: 'profiles.modals.editor.enhance.globalScript',
|
||||||
|
profileMerge: 'profiles.modals.editor.enhance.profileMerge',
|
||||||
|
profileScript: 'profiles.modals.editor.enhance.profileScript',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stage: keyof typeof stageHints
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnhanceHint = ({ stage }: Props) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const orderComponents = Object.fromEntries(
|
||||||
|
Object.keys(stageHints).map((key) => [
|
||||||
|
key,
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
component={key === stage ? 'strong' : 'span'}
|
||||||
|
sx={
|
||||||
|
key === stage
|
||||||
|
? {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
borderRadius: 0.5,
|
||||||
|
px: 0.5,
|
||||||
|
boxDecorationBreak: 'clone',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity="info" sx={{ mb: 1.5, flexShrink: 0 }}>
|
||||||
|
<Typography variant="body2">{t(stageHints[stage])}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||||
|
<Trans
|
||||||
|
defaults={t('profiles.modals.editor.enhance.order')}
|
||||||
|
components={orderComponents}
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5 }}>
|
||||||
|
{t('profiles.modals.editor.enhance.settingsPriority')}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { move } from '@dnd-kit/helpers'
|
||||||
|
import {
|
||||||
|
DragDropProvider,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragOverEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
} from '@dnd-kit/react'
|
||||||
|
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { type CSSProperties, type ReactNode, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { SortableItem } from '../base'
|
||||||
|
|
||||||
|
type GroupedVirtualCategory = 'prepend' | 'original' | 'append'
|
||||||
|
|
||||||
|
export interface GroupedVirtualItem<T> {
|
||||||
|
id: string
|
||||||
|
category: GroupedVirtualCategory
|
||||||
|
item: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function buildGroupedItems<T>(
|
||||||
|
prepend: T[],
|
||||||
|
original: T[],
|
||||||
|
append: T[],
|
||||||
|
getKey: (item: T) => string,
|
||||||
|
): GroupedVirtualItem<T>[] {
|
||||||
|
const build = (
|
||||||
|
category: GroupedVirtualCategory,
|
||||||
|
list: T[],
|
||||||
|
): GroupedVirtualItem<T>[] =>
|
||||||
|
list.map((item) => ({
|
||||||
|
id: `${category}:${getKey(item)}`,
|
||||||
|
category,
|
||||||
|
item,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
...build('prepend', prepend),
|
||||||
|
...build('original', original),
|
||||||
|
...build('append', append),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedVirtualListProps<T> {
|
||||||
|
items: GroupedVirtualItem<T>[]
|
||||||
|
estimateSize?: number
|
||||||
|
renderItem: (entry: GroupedVirtualItem<T>) => ReactNode
|
||||||
|
onReorder: (
|
||||||
|
category: 'prepend' | 'append',
|
||||||
|
activeIndex: number,
|
||||||
|
overIndex: number,
|
||||||
|
) => void
|
||||||
|
style?: CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于编辑规则/节点/代理组功能的 prepend/original/append 分组虚拟化列表
|
||||||
|
*/
|
||||||
|
export function GroupedVirtualList<T>(props: GroupedVirtualListProps<T>) {
|
||||||
|
const {
|
||||||
|
items: itemsProp,
|
||||||
|
estimateSize = 56,
|
||||||
|
renderItem,
|
||||||
|
onReorder,
|
||||||
|
style,
|
||||||
|
} = props
|
||||||
|
const [items, setItems] = useState(itemsProp)
|
||||||
|
const [prevItemsProp, setPrevItemsProp] = useState(itemsProp)
|
||||||
|
const snapshotRef = useRef(itemsProp)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
if (itemsProp !== prevItemsProp) {
|
||||||
|
setPrevItemsProp(itemsProp)
|
||||||
|
setItems(itemsProp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => estimateSize,
|
||||||
|
overscan: 15,
|
||||||
|
gap: 8,
|
||||||
|
getItemKey: (index) => items[index]?.id ?? index,
|
||||||
|
})
|
||||||
|
|
||||||
|
const onDragStart = () => {
|
||||||
|
snapshotRef.current = items
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (event: DragOverEvent) => {
|
||||||
|
const { operation } = event
|
||||||
|
if (!isSortableOperation(operation)) return
|
||||||
|
|
||||||
|
const { source, target } = operation
|
||||||
|
if (!source || !target) return
|
||||||
|
|
||||||
|
const sourceEntry = items.find((entry) => entry.id === source.id)
|
||||||
|
const targetEntry = items.find((entry) => entry.id === target.id)
|
||||||
|
if (
|
||||||
|
!sourceEntry ||
|
||||||
|
!targetEntry ||
|
||||||
|
sourceEntry.category !== targetEntry.category
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((current) => move(current, event))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEnd = (event: DragEndEvent) => {
|
||||||
|
const source = event.operation.source
|
||||||
|
|
||||||
|
if (event.canceled) {
|
||||||
|
setItems(snapshotRef.current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSortable(source)) return
|
||||||
|
|
||||||
|
const snapshot = snapshotRef.current
|
||||||
|
const sourceEntry = snapshot.find((entry) => entry.id === source.id)
|
||||||
|
if (!sourceEntry) return
|
||||||
|
|
||||||
|
const { category } = sourceEntry
|
||||||
|
if (category !== 'prepend' && category !== 'append') return
|
||||||
|
|
||||||
|
const activeIndex = snapshot
|
||||||
|
.filter((entry) => entry.category === category)
|
||||||
|
.findIndex((entry) => entry.id === source.id)
|
||||||
|
const overIndex = items
|
||||||
|
.filter((entry) => entry.category === category)
|
||||||
|
.findIndex((entry) => entry.id === source.id)
|
||||||
|
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) return
|
||||||
|
|
||||||
|
onReorder(category, activeIndex, overIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropProvider
|
||||||
|
sensors={[PointerSensor, KeyboardSensor]}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<div ref={scrollRef} style={{ ...style, overflow: 'auto' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: virtualizer.getTotalSize(),
|
||||||
|
position: 'relative',
|
||||||
|
marginRight: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((vi) => {
|
||||||
|
const entry = items[vi.index]
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SortableItem
|
||||||
|
key={vi.key}
|
||||||
|
id={entry.id}
|
||||||
|
index={vi.index}
|
||||||
|
disabled={entry.category === 'original'}
|
||||||
|
measureElement={virtualizer.measureElement}
|
||||||
|
dataIndex={vi.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${vi.start}px`,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(entry)}
|
||||||
|
</SortableItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DragDropProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
import { arrayMove } from '@dnd-kit/helpers'
|
import { arrayMove } from '@dnd-kit/helpers'
|
||||||
import {
|
|
||||||
DragDropProvider,
|
|
||||||
type DragOverEvent,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from '@dnd-kit/react'
|
|
||||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
|
||||||
import {
|
import {
|
||||||
VerticalAlignBottomRounded,
|
VerticalAlignBottomRounded,
|
||||||
VerticalAlignTopRounded,
|
VerticalAlignTopRounded,
|
||||||
@@ -43,13 +35,7 @@ import {
|
|||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import {
|
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||||
BaseSearchBox,
|
|
||||||
MonacoEditor,
|
|
||||||
SortableItem,
|
|
||||||
Switch,
|
|
||||||
VirtualList,
|
|
||||||
} from '@/components/base'
|
|
||||||
import { GroupItem } from '@/components/profile/group-item'
|
import { GroupItem } from '@/components/profile/group-item'
|
||||||
import {
|
import {
|
||||||
getNetworkInterfaces,
|
getNetworkInterfaces,
|
||||||
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
|
|||||||
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
||||||
import { parseYamlSafe } from '@/utils/yaml'
|
import { parseYamlSafe } from '@/utils/yaml'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildGroupedItems,
|
||||||
|
type GroupedVirtualItem,
|
||||||
|
GroupedVirtualList,
|
||||||
|
} from './grouped-virtual-list'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
proxiesUid: string
|
proxiesUid: string
|
||||||
mergeUid: string
|
mergeUid: string
|
||||||
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[appendSeq, match],
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderItem = (index: number): React.ReactNode => {
|
const items = useMemo(
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
() =>
|
||||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
buildGroupedItems(
|
||||||
|
filteredPrependSeq,
|
||||||
|
filteredGroupList,
|
||||||
|
filteredAppendSeq,
|
||||||
|
(group) => group.name,
|
||||||
|
),
|
||||||
|
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
|
||||||
|
const { category, item } = entry
|
||||||
|
|
||||||
|
if (category === 'original') {
|
||||||
|
const isDeleted = deleteSeq.includes(item.name)
|
||||||
return (
|
return (
|
||||||
<>
|
<GroupItem
|
||||||
{filteredPrependSeq.map((item, itemIndex) => {
|
type={isDeleted ? 'delete' : 'original'}
|
||||||
return (
|
group={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item.name}
|
if (isDeleted) {
|
||||||
id={`prepend:${item.name}`}
|
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||||
index={itemIndex}
|
} else {
|
||||||
group="prepend"
|
setDeleteSeq((prev) => [...prev, item.name])
|
||||||
style={{ margin: '8px 0' }}
|
|
||||||
>
|
|
||||||
<GroupItem
|
|
||||||
type="prepend"
|
|
||||||
group={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setPrependSeq(
|
|
||||||
prependSeq.filter((v) => v.name !== item.name),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
} else if (index < filteredGroupList.length + shift) {
|
|
||||||
const newIndex = index - shift
|
|
||||||
return (
|
|
||||||
<Box sx={{ margin: '8px 0' }}>
|
|
||||||
<GroupItem
|
|
||||||
key={filteredGroupList[newIndex].name}
|
|
||||||
type={
|
|
||||||
deleteSeq.includes(filteredGroupList[newIndex].name)
|
|
||||||
? 'delete'
|
|
||||||
: 'original'
|
|
||||||
}
|
}
|
||||||
group={filteredGroupList[newIndex]}
|
}}
|
||||||
onDelete={() => {
|
/>
|
||||||
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
|
|
||||||
setDeleteSeq(
|
|
||||||
deleteSeq.filter(
|
|
||||||
(v) => v !== filteredGroupList[newIndex].name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setDeleteSeq((prev) => [
|
|
||||||
...prev,
|
|
||||||
filteredGroupList[newIndex].name,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (category === 'prepend') {
|
||||||
return (
|
return (
|
||||||
<>
|
<GroupItem
|
||||||
{filteredAppendSeq.map((item, itemIndex) => {
|
type="prepend"
|
||||||
return (
|
group={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item.name}
|
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||||
id={`append:${item.name}`}
|
}}
|
||||||
index={itemIndex}
|
/>
|
||||||
group="append"
|
|
||||||
style={{ margin: '8px 0' }}
|
|
||||||
>
|
|
||||||
<GroupItem
|
|
||||||
type="append"
|
|
||||||
group={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupItem
|
||||||
|
type="append"
|
||||||
|
group={item}
|
||||||
|
onDelete={() => {
|
||||||
|
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
const onReorder = (
|
||||||
const { operation, canceled } = event
|
category: 'prepend' | 'append',
|
||||||
const { source, target } = operation
|
activeIndex: number,
|
||||||
if (canceled || !target || !isSortable(source)) return
|
overIndex: number,
|
||||||
|
) => {
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||||
const activeRealIndex = findRealIndex(
|
const filtered =
|
||||||
prependSeq,
|
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||||
filteredPrependSeq,
|
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||||
activeIndex,
|
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||||
)
|
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||||
const overRealIndex = findRealIndex(
|
|
||||||
prependSeq,
|
|
||||||
filteredPrependSeq,
|
|
||||||
overIndex,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
activeRealIndex < 0 ||
|
activeRealIndex < 0 ||
|
||||||
overRealIndex < 0 ||
|
overRealIndex < 0 ||
|
||||||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||||
}
|
|
||||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const { operation, canceled } = event
|
|
||||||
const { source, target } = operation
|
|
||||||
if (canceled || !target || !isSortable(source)) return
|
|
||||||
|
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
|
||||||
const activeRealIndex = findRealIndex(
|
|
||||||
appendSeq,
|
|
||||||
filteredAppendSeq,
|
|
||||||
activeIndex,
|
|
||||||
)
|
|
||||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
|
||||||
if (
|
|
||||||
activeRealIndex < 0 ||
|
|
||||||
overRealIndex < 0 ||
|
|
||||||
activeRealIndex === overRealIndex
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent drag over if source and target are in different groups
|
|
||||||
const onDragOver = (event: DragOverEvent) => {
|
|
||||||
const { operation } = event
|
|
||||||
if (!isSortableOperation(operation)) return
|
|
||||||
|
|
||||||
const { source, target } = operation
|
|
||||||
if (source?.group !== target?.group) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const source = event.operation.source
|
|
||||||
if (!isSortable(source)) return
|
|
||||||
|
|
||||||
const { initialGroup, group } = source.sortable
|
|
||||||
if (initialGroup !== group) return
|
|
||||||
|
|
||||||
if (group === 'prepend') {
|
|
||||||
await onPrependDragEnd(event)
|
|
||||||
} else if (group === 'append') {
|
|
||||||
await onAppendDragEnd(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fetchContent = useCallback(async () => {
|
const fetchContent = useCallback(async () => {
|
||||||
hasLoadedSeqConfigRef.current = false
|
hasLoadedSeqConfigRef.current = false
|
||||||
const data = await readProfileFile(property)
|
const data = await readProfileFile(property)
|
||||||
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<DragDropProvider
|
<GroupedVirtualList
|
||||||
sensors={[PointerSensor, KeyboardSensor]}
|
items={items}
|
||||||
onDragOver={onDragOver}
|
renderItem={renderItem}
|
||||||
onDragEnd={onDragEnd}
|
onReorder={onReorder}
|
||||||
>
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
<VirtualList
|
/>
|
||||||
count={
|
|
||||||
filteredGroupList.length +
|
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
|
||||||
}
|
|
||||||
estimateSize={56}
|
|
||||||
renderItem={renderItem}
|
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
|
||||||
/>
|
|
||||||
</DragDropProvider>
|
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ import { showNotice } from '@/services/notice-service'
|
|||||||
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
|
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
|
||||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||||
import { debugLog } from '@/utils/debug'
|
import { debugLog } from '@/utils/debug'
|
||||||
|
import { isValidUrl } from '@/utils/network'
|
||||||
import { openExternalUrl } from '@/utils/open-external-url'
|
import { openExternalUrl } from '@/utils/open-external-url'
|
||||||
import parseTraffic from '@/utils/parse-traffic'
|
import parseTraffic from '@/utils/parse-traffic'
|
||||||
|
|
||||||
|
import { EnhanceHint } from './enhance-hint'
|
||||||
import { ProfileBox } from './profile-box'
|
import { ProfileBox } from './profile-box'
|
||||||
import { ProxiesEditorViewer } from './proxies-editor-viewer'
|
import { ProxiesEditorViewer } from './proxies-editor-viewer'
|
||||||
import { QrViewer } from './qr-viewer'
|
import { QrViewer } from './qr-viewer'
|
||||||
@@ -909,6 +911,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
|||||||
{mergeOpen && (
|
{mergeOpen && (
|
||||||
<EditorViewer
|
<EditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
|
title={t('profiles.components.menu.extendConfig')}
|
||||||
|
description={<EnhanceHint stage="profileMerge" />}
|
||||||
value={mergeDocument.value}
|
value={mergeDocument.value}
|
||||||
language="yaml"
|
language="yaml"
|
||||||
path={`merge:${option?.merge ?? ''}.yaml`}
|
path={`merge:${option?.merge ?? ''}.yaml`}
|
||||||
@@ -922,6 +926,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
|||||||
{scriptOpen && (
|
{scriptOpen && (
|
||||||
<EditorViewer
|
<EditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
|
title={t('profiles.components.menu.extendScript')}
|
||||||
|
description={<EnhanceHint stage="profileScript" />}
|
||||||
value={scriptDocument.value}
|
value={scriptDocument.value}
|
||||||
language="javascript"
|
language="javascript"
|
||||||
path={`script:${option?.script ?? ''}.js`}
|
path={`script:${option?.script ?? ''}.js`}
|
||||||
@@ -965,9 +971,7 @@ export const ProfileItem = memo(ProfileItemBase)
|
|||||||
|
|
||||||
function parseUrl(url?: string) {
|
function parseUrl(url?: string) {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
const regex = /https?:\/\/(.+?)\//
|
return isValidUrl(url) ? new URL(url).host : 'local file'
|
||||||
const result = url.match(regex)
|
|
||||||
return result ? result[1] : 'local file'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExpire(expire?: number) {
|
function parseExpire(expire?: number) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useEditorDocument } from '@/hooks/use-editor-document'
|
|||||||
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
|
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
|
|
||||||
|
import { EnhanceHint } from './enhance-hint'
|
||||||
import { LogViewer } from './log-viewer'
|
import { LogViewer } from './log-viewer'
|
||||||
import { ProfileBox } from './profile-box'
|
import { ProfileBox } from './profile-box'
|
||||||
|
|
||||||
@@ -213,6 +214,11 @@ export const ProfileMore = (props: Props) => {
|
|||||||
<EditorViewer
|
<EditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
title={t(globalTitles[id])}
|
title={t(globalTitles[id])}
|
||||||
|
description={
|
||||||
|
<EnhanceHint
|
||||||
|
stage={id === 'Merge' ? 'globalMerge' : 'globalScript'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
value={document.value}
|
value={document.value}
|
||||||
language={id === 'Merge' ? 'yaml' : 'javascript'}
|
language={id === 'Merge' ? 'yaml' : 'javascript'}
|
||||||
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
|
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import { arrayMove } from '@dnd-kit/helpers'
|
import { arrayMove } from '@dnd-kit/helpers'
|
||||||
import {
|
|
||||||
DragDropProvider,
|
|
||||||
type DragOverEvent,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from '@dnd-kit/react'
|
|
||||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
|
||||||
import {
|
import {
|
||||||
VerticalAlignBottomRounded,
|
VerticalAlignBottomRounded,
|
||||||
VerticalAlignTopRounded,
|
VerticalAlignTopRounded,
|
||||||
@@ -35,12 +27,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import {
|
import { BaseSearchBox, MonacoEditor } from '@/components/base'
|
||||||
BaseSearchBox,
|
|
||||||
MonacoEditor,
|
|
||||||
SortableItem,
|
|
||||||
VirtualList,
|
|
||||||
} from '@/components/base'
|
|
||||||
import { ProxyItem } from '@/components/profile/proxy-item'
|
import { ProxyItem } from '@/components/profile/proxy-item'
|
||||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
|||||||
import parseUri from '@/utils/uri-parser'
|
import parseUri from '@/utils/uri-parser'
|
||||||
import { parseYamlSafe } from '@/utils/yaml'
|
import { parseYamlSafe } from '@/utils/yaml'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildGroupedItems,
|
||||||
|
type GroupedVirtualItem,
|
||||||
|
GroupedVirtualList,
|
||||||
|
} from './grouped-virtual-list'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
profileUid: string
|
profileUid: string
|
||||||
property: string
|
property: string
|
||||||
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[appendSeq, match],
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderItem = (index: number): React.ReactNode => {
|
const items = useMemo(
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
() =>
|
||||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
buildGroupedItems(
|
||||||
|
filteredPrependSeq,
|
||||||
|
filteredProxyList,
|
||||||
|
filteredAppendSeq,
|
||||||
|
(proxy) => proxy.name,
|
||||||
|
),
|
||||||
|
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
|
||||||
|
const { category, item } = entry
|
||||||
|
|
||||||
|
if (category === 'original') {
|
||||||
|
const isDeleted = deleteSeq.includes(item.name)
|
||||||
return (
|
return (
|
||||||
<>
|
<ProxyItem
|
||||||
{filteredPrependSeq.map((item, itemIndex) => {
|
type={isDeleted ? 'delete' : 'original'}
|
||||||
return (
|
proxy={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item.name}
|
if (isDeleted) {
|
||||||
id={`prepend:${item.name}`}
|
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
|
||||||
index={itemIndex}
|
} else {
|
||||||
group="prepend"
|
setDeleteSeq((prev) => [...prev, item.name])
|
||||||
style={{ margin: '8px 0' }}
|
|
||||||
>
|
|
||||||
<ProxyItem
|
|
||||||
type="prepend"
|
|
||||||
proxy={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setPrependSeq(
|
|
||||||
prependSeq.filter((v) => v.name !== item.name),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
} else if (index < filteredProxyList.length + shift) {
|
|
||||||
const newIndex = index - shift
|
|
||||||
return (
|
|
||||||
<Box sx={{ margin: '8px 0' }}>
|
|
||||||
<ProxyItem
|
|
||||||
key={filteredProxyList[newIndex].name}
|
|
||||||
type={
|
|
||||||
deleteSeq.includes(filteredProxyList[newIndex].name)
|
|
||||||
? 'delete'
|
|
||||||
: 'original'
|
|
||||||
}
|
}
|
||||||
proxy={filteredProxyList[newIndex]}
|
}}
|
||||||
onDelete={() => {
|
/>
|
||||||
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
|
|
||||||
setDeleteSeq(
|
|
||||||
deleteSeq.filter(
|
|
||||||
(v) => v !== filteredProxyList[newIndex].name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setDeleteSeq((prev) => [
|
|
||||||
...prev,
|
|
||||||
filteredProxyList[newIndex].name,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (category === 'prepend') {
|
||||||
return (
|
return (
|
||||||
<>
|
<ProxyItem
|
||||||
{filteredAppendSeq.map((item, itemIndex) => {
|
type="prepend"
|
||||||
return (
|
proxy={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item.name}
|
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
|
||||||
id={`append:${item.name}`}
|
}}
|
||||||
index={itemIndex}
|
/>
|
||||||
group="append"
|
|
||||||
style={{ margin: '8px 0' }}
|
|
||||||
>
|
|
||||||
<ProxyItem
|
|
||||||
type="append"
|
|
||||||
proxy={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProxyItem
|
||||||
|
type="append"
|
||||||
|
proxy={item}
|
||||||
|
onDelete={() => {
|
||||||
|
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
const onReorder = (
|
||||||
const { operation, canceled } = event
|
category: 'prepend' | 'append',
|
||||||
const { source, target } = operation
|
activeIndex: number,
|
||||||
if (canceled || !target || !isSortable(source)) return
|
overIndex: number,
|
||||||
|
) => {
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||||
const activeRealIndex = findRealIndex(
|
const filtered =
|
||||||
prependSeq,
|
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||||
filteredPrependSeq,
|
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||||
activeIndex,
|
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||||
)
|
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||||
const overRealIndex = findRealIndex(
|
|
||||||
prependSeq,
|
|
||||||
filteredPrependSeq,
|
|
||||||
overIndex,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
activeRealIndex < 0 ||
|
activeRealIndex < 0 ||
|
||||||
overRealIndex < 0 ||
|
overRealIndex < 0 ||
|
||||||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
|
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||||
}
|
}
|
||||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const { operation, canceled } = event
|
|
||||||
const { source, target } = operation
|
|
||||||
if (canceled || !target || !isSortable(source)) return
|
|
||||||
|
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
|
||||||
const activeRealIndex = findRealIndex(
|
|
||||||
appendSeq,
|
|
||||||
filteredAppendSeq,
|
|
||||||
activeIndex,
|
|
||||||
)
|
|
||||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
|
||||||
if (
|
|
||||||
activeRealIndex < 0 ||
|
|
||||||
overRealIndex < 0 ||
|
|
||||||
activeRealIndex === overRealIndex
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
|
|
||||||
}
|
|
||||||
const onDragOver = (event: DragOverEvent) => {
|
|
||||||
const { operation } = event
|
|
||||||
if (!isSortableOperation(operation)) return
|
|
||||||
|
|
||||||
const { source, target } = operation
|
|
||||||
if (source?.group !== target?.group) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const onDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const source = event.operation.source
|
|
||||||
if (!isSortable(source)) return
|
|
||||||
|
|
||||||
const { initialGroup, group } = source.sortable
|
|
||||||
if (initialGroup !== group) return
|
|
||||||
|
|
||||||
if (group === 'prepend') {
|
|
||||||
await onPrependDragEnd(event)
|
|
||||||
} else if (group === 'append') {
|
|
||||||
await onAppendDragEnd(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
|
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
|
||||||
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
|
||||||
const proxies: IProxyConfig[] = []
|
const proxies: IProxyConfig[] = []
|
||||||
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<DragDropProvider
|
<GroupedVirtualList
|
||||||
sensors={[PointerSensor, KeyboardSensor]}
|
items={items}
|
||||||
onDragOver={onDragOver}
|
renderItem={renderItem}
|
||||||
onDragEnd={onDragEnd}
|
onReorder={onReorder}
|
||||||
>
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
<VirtualList
|
/>
|
||||||
count={
|
|
||||||
filteredProxyList.length +
|
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
|
||||||
}
|
|
||||||
estimateSize={56}
|
|
||||||
renderItem={renderItem}
|
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
|
||||||
/>
|
|
||||||
</DragDropProvider>
|
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { arrayMove } from '@dnd-kit/helpers'
|
||||||
DragDropProvider,
|
|
||||||
type DragOverEvent,
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from '@dnd-kit/react'
|
|
||||||
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
|
|
||||||
import {
|
import {
|
||||||
VerticalAlignBottomRounded,
|
VerticalAlignBottomRounded,
|
||||||
VerticalAlignTopRounded,
|
VerticalAlignTopRounded,
|
||||||
@@ -36,13 +29,7 @@ import {
|
|||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import {
|
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||||
BaseSearchBox,
|
|
||||||
MonacoEditor,
|
|
||||||
SortableItem,
|
|
||||||
Switch,
|
|
||||||
VirtualList,
|
|
||||||
} from '@/components/base'
|
|
||||||
import { RuleItem } from '@/components/profile/rule-item'
|
import { RuleItem } from '@/components/profile/rule-item'
|
||||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
|
|||||||
import { isValidIpCidr } from '@/utils/network'
|
import { isValidIpCidr } from '@/utils/network'
|
||||||
import { parseYamlSafe } from '@/utils/yaml'
|
import { parseYamlSafe } from '@/utils/yaml'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildGroupedItems,
|
||||||
|
type GroupedVirtualItem,
|
||||||
|
GroupedVirtualList,
|
||||||
|
} from './grouped-virtual-list'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
groupsUid: string
|
groupsUid: string
|
||||||
mergeUid: string
|
mergeUid: string
|
||||||
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
[appendSeq, match],
|
[appendSeq, match],
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderItem = (index: number): React.ReactNode => {
|
const items = useMemo(
|
||||||
const shift = filteredPrependSeq.length > 0 ? 1 : 0
|
() =>
|
||||||
if (filteredPrependSeq.length > 0 && index === 0) {
|
buildGroupedItems(
|
||||||
|
filteredPrependSeq,
|
||||||
|
filteredRuleList,
|
||||||
|
filteredAppendSeq,
|
||||||
|
(rule) => rule,
|
||||||
|
),
|
||||||
|
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderItem = (entry: GroupedVirtualItem<string>) => {
|
||||||
|
const { category, item } = entry
|
||||||
|
|
||||||
|
if (category === 'original') {
|
||||||
|
const isDeleted = deleteSeq.includes(item)
|
||||||
return (
|
return (
|
||||||
<>
|
<RuleItem
|
||||||
{filteredPrependSeq.map((item, itemIndex) => {
|
type={isDeleted ? 'delete' : 'original'}
|
||||||
return (
|
ruleRaw={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item}
|
if (isDeleted) {
|
||||||
id={`prepend:${item}`}
|
setDeleteSeq(deleteSeq.filter((v) => v !== item))
|
||||||
index={itemIndex}
|
} else {
|
||||||
group="prepend"
|
setDeleteSeq((prev) => [...prev, item])
|
||||||
style={{ margin: '8px 0' }}
|
|
||||||
>
|
|
||||||
<RuleItem
|
|
||||||
type="prepend"
|
|
||||||
ruleRaw={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
|
||||||
}}
|
|
||||||
onAppend={() => {
|
|
||||||
setAppendSeq((prev) =>
|
|
||||||
prev.includes(item) ? prev : [...prev, item],
|
|
||||||
)
|
|
||||||
setPrependSeq(prependSeq.filter((v) => v !== item))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
} else if (index < filteredRuleList.length + shift) {
|
|
||||||
const newIndex = index - shift
|
|
||||||
return (
|
|
||||||
<Box sx={{ margin: '8px 0' }}>
|
|
||||||
<RuleItem
|
|
||||||
key={filteredRuleList[newIndex]}
|
|
||||||
type={
|
|
||||||
deleteSeq.includes(filteredRuleList[newIndex])
|
|
||||||
? 'delete'
|
|
||||||
: 'original'
|
|
||||||
}
|
}
|
||||||
ruleRaw={filteredRuleList[newIndex]}
|
}}
|
||||||
onDelete={() => {
|
/>
|
||||||
if (deleteSeq.includes(filteredRuleList[newIndex])) {
|
|
||||||
setDeleteSeq(
|
|
||||||
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (category === 'prepend') {
|
||||||
return (
|
return (
|
||||||
<>
|
<RuleItem
|
||||||
{filteredAppendSeq.map((item, itemIndex) => {
|
type="prepend"
|
||||||
return (
|
ruleRaw={item}
|
||||||
<SortableItem
|
onDelete={() => {
|
||||||
key={item}
|
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||||
id={`append:${item}`}
|
}}
|
||||||
index={itemIndex}
|
onAppend={() => {
|
||||||
group="append"
|
setAppendSeq((prev) =>
|
||||||
style={{ margin: '8px 0' }}
|
prev.includes(item) ? prev : [...prev, item],
|
||||||
>
|
|
||||||
<RuleItem
|
|
||||||
type="append"
|
|
||||||
ruleRaw={item}
|
|
||||||
onDelete={() => {
|
|
||||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
|
||||||
}}
|
|
||||||
onPrepend={() => {
|
|
||||||
setPrependSeq((prev) =>
|
|
||||||
prev.includes(item) ? prev : [...prev, item],
|
|
||||||
)
|
|
||||||
setAppendSeq(appendSeq.filter((v) => v !== item))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
)
|
)
|
||||||
})}
|
setPrependSeq(prependSeq.filter((v) => v !== item))
|
||||||
</>
|
}}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RuleItem
|
||||||
|
type="append"
|
||||||
|
ruleRaw={item}
|
||||||
|
onDelete={() => {
|
||||||
|
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||||
|
}}
|
||||||
|
onPrepend={() => {
|
||||||
|
setPrependSeq((prev) =>
|
||||||
|
prev.includes(item) ? prev : [...prev, item],
|
||||||
|
)
|
||||||
|
setAppendSeq(appendSeq.filter((v) => v !== item))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reorder = (list: string[], startIndex: number, endIndex: number) => {
|
const onReorder = (
|
||||||
const result = Array.from(list)
|
category: 'prepend' | 'append',
|
||||||
const [removed] = result.splice(startIndex, 1)
|
activeIndex: number,
|
||||||
result.splice(endIndex, 0, removed)
|
overIndex: number,
|
||||||
return result
|
) => {
|
||||||
}
|
const list = category === 'prepend' ? prependSeq : appendSeq
|
||||||
const onPrependDragEnd = async (event: DragEndEvent) => {
|
const filtered =
|
||||||
const { operation, canceled } = event
|
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
||||||
const { source, target } = operation
|
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
||||||
if (canceled || !target || !isSortable(source)) return
|
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
||||||
|
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
|
||||||
const activeRealIndex = findRealIndex(
|
|
||||||
prependSeq,
|
|
||||||
filteredPrependSeq,
|
|
||||||
activeIndex,
|
|
||||||
)
|
|
||||||
const overRealIndex = findRealIndex(
|
|
||||||
prependSeq,
|
|
||||||
filteredPrependSeq,
|
|
||||||
overIndex,
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
activeRealIndex < 0 ||
|
activeRealIndex < 0 ||
|
||||||
overRealIndex < 0 ||
|
overRealIndex < 0 ||
|
||||||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
|
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
||||||
}
|
}
|
||||||
const onAppendDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const { operation, canceled } = event
|
|
||||||
const { source, target } = operation
|
|
||||||
if (canceled || !target || !isSortable(source)) return
|
|
||||||
|
|
||||||
const { index: overIndex, initialIndex: activeIndex } = source.sortable
|
|
||||||
const activeRealIndex = findRealIndex(
|
|
||||||
appendSeq,
|
|
||||||
filteredAppendSeq,
|
|
||||||
activeIndex,
|
|
||||||
)
|
|
||||||
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
|
|
||||||
if (
|
|
||||||
activeRealIndex < 0 ||
|
|
||||||
overRealIndex < 0 ||
|
|
||||||
activeRealIndex === overRealIndex
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
|
|
||||||
}
|
|
||||||
const onDragOver = (event: DragOverEvent) => {
|
|
||||||
const { operation } = event
|
|
||||||
if (!isSortableOperation(operation)) return
|
|
||||||
|
|
||||||
const { source, target } = operation
|
|
||||||
if (source?.group !== target?.group) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const onDragEnd = async (event: DragEndEvent) => {
|
|
||||||
const source = event.operation.source
|
|
||||||
if (!isSortable(source)) return
|
|
||||||
|
|
||||||
const { initialGroup, group } = source.sortable
|
|
||||||
if (initialGroup !== group) return
|
|
||||||
|
|
||||||
if (group === 'prepend') {
|
|
||||||
await onPrependDragEnd(event)
|
|
||||||
} else if (group === 'append') {
|
|
||||||
await onAppendDragEnd(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fetchContent = useCallback(async () => {
|
const fetchContent = useCallback(async () => {
|
||||||
hasLoadedSeqConfigRef.current = false
|
hasLoadedSeqConfigRef.current = false
|
||||||
const data = await readProfileFile(property)
|
const data = await readProfileFile(property)
|
||||||
@@ -765,6 +681,13 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ minWidth: '240px' }}
|
sx={{ minWidth: '240px' }}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
autoCorrect: 'off',
|
||||||
|
autoCapitalize: 'off',
|
||||||
|
spellCheck: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
value={ruleContent}
|
value={ruleContent}
|
||||||
required={ruleType.required ?? true}
|
required={ruleType.required ?? true}
|
||||||
error={(ruleType.required ?? true) && !ruleContent}
|
error={(ruleType.required ?? true) && !ruleContent}
|
||||||
@@ -854,22 +777,12 @@ export const RulesEditorViewer = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||||
<DragDropProvider
|
<GroupedVirtualList
|
||||||
sensors={[PointerSensor, KeyboardSensor]}
|
items={items}
|
||||||
onDragOver={onDragOver}
|
renderItem={renderItem}
|
||||||
onDragEnd={onDragEnd}
|
onReorder={onReorder}
|
||||||
>
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
||||||
<VirtualList
|
/>
|
||||||
count={
|
|
||||||
filteredRuleList.length +
|
|
||||||
(filteredPrependSeq.length > 0 ? 1 : 0) +
|
|
||||||
(filteredAppendSeq.length > 0 ? 1 : 0)
|
|
||||||
}
|
|
||||||
estimateSize={56}
|
|
||||||
renderItem={renderItem}
|
|
||||||
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
|
||||||
/>
|
|
||||||
</DragDropProvider>
|
|
||||||
</List>
|
</List>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import delayManager from '@/services/delay'
|
import delayManager from '@/services/delay'
|
||||||
|
import { showNotice } from '@/services/notice-service'
|
||||||
|
import { isValidUrl } from '@/utils/network'
|
||||||
|
|
||||||
import { BaseSearchBox, type SearchState } from '../base'
|
import { BaseSearchBox, type SearchState } from '../base'
|
||||||
|
|
||||||
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
|
|||||||
if (testUrl?.trim() && textState !== 'filter') {
|
if (testUrl?.trim() && textState !== 'filter') {
|
||||||
onHeadState({ textState: 'url' })
|
onHeadState({ textState: 'url' })
|
||||||
}
|
}
|
||||||
|
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||||
|
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||||
|
return
|
||||||
|
}
|
||||||
onCheckDelay()
|
onCheckDelay()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { BaseSearchBox } from '@/components/base'
|
import { BaseSearchBox } from '@/components/base'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import delayManager from '@/services/delay'
|
import delayManager from '@/services/delay'
|
||||||
|
import { showNotice } from '@/services/notice-service'
|
||||||
import { debugLog } from '@/utils/debug'
|
import { debugLog } from '@/utils/debug'
|
||||||
|
import { isValidUrl } from '@/utils/network'
|
||||||
|
|
||||||
import type { ProxySortType } from './use-filter-sort'
|
import type { ProxySortType } from './use-filter-sort'
|
||||||
import type { HeadState } from './use-head-state'
|
import type { HeadState } from './use-head-state'
|
||||||
@@ -95,6 +97,10 @@ export const ProxyHead = ({
|
|||||||
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
|
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
|
||||||
onHeadState({ textState: 'url' })
|
onHeadState({ textState: 'url' })
|
||||||
}
|
}
|
||||||
|
if (testUrl?.trim() && !isValidUrl(testUrl)) {
|
||||||
|
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
|
||||||
|
return
|
||||||
|
}
|
||||||
onCheckDelay()
|
onCheckDelay()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user