mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4287970bc | ||
|
|
b20fdc9b96 | ||
|
|
5b10331022 | ||
|
|
3ec858eb9f | ||
|
|
3693efcf6c | ||
|
|
6e08a77a84 | ||
|
|
27efaf34f4 | ||
|
|
585c67f8c5 | ||
|
|
d6a2717fc4 | ||
|
|
84312a5cd1 | ||
|
|
63ee674dcb | ||
|
|
52dca35acb | ||
|
|
44477d97d4 | ||
|
|
0fefa7a7ff | ||
|
|
b6d94d45d5 | ||
|
|
9975322b9b | ||
|
|
4337bd8575 | ||
|
|
cb751052a4 | ||
|
|
d9dc18a57b | ||
|
|
6b4d4e1551 | ||
|
|
4a125681bd | ||
|
|
1c2817023e | ||
|
|
89076f01c3 | ||
|
|
a4531cf926 | ||
|
|
07b0e71cc6 | ||
|
|
ad2baf254a | ||
|
|
d01f43cc78 | ||
|
|
720dc13e96 | ||
|
|
69b76cc6a9 | ||
|
|
8328e34208 | ||
|
|
78691ea5c8 | ||
|
|
af808a677c | ||
|
|
a1bebf6de1 | ||
|
|
2caff762b5 | ||
|
|
dba0f471bf | ||
|
|
c341b9b238 | ||
|
|
6ec35c8195 | ||
|
|
30f05a7a39 | ||
|
|
7cc6c1ed95 | ||
|
|
c7cee50fc1 | ||
|
|
4cae1ff132 | ||
|
|
1a01844cb8 | ||
|
|
1575f134e0 | ||
|
|
a9a3cfa3a7 | ||
|
|
14770df157 | ||
|
|
57c5484080 | ||
|
|
4a0882fc18 | ||
|
|
d87bb7b6a3 | ||
|
|
a58cefdc31 | ||
|
|
f958dccb25 | ||
|
|
8c1fb3ac56 | ||
|
|
3607e66bb3 | ||
|
|
ea509b8236 | ||
|
|
52d616302c | ||
|
|
8ddd99f226 | ||
|
|
e0e97a920e | ||
|
|
c4f9d65e07 | ||
|
|
0c7e0c8535 | ||
|
|
1638ab48d8 | ||
|
|
5d7dc3aed9 | ||
|
|
b057bd964c | ||
|
|
897a117dc5 | ||
|
|
6a752994cf | ||
|
|
32e5d5bda3 | ||
|
|
daae17a40c | ||
|
|
1f140dfd3a | ||
|
|
04d74ac48f | ||
|
|
78bf79cc09 | ||
|
|
61c33c6113 | ||
|
|
ae4f416766 | ||
|
|
fbc4b516b8 | ||
|
|
3456de1fb6 | ||
|
|
f9ef9d78cb | ||
|
|
3458c9b075 | ||
|
|
afc32f568a | ||
|
|
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,10 +5,10 @@
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "24.21.0",
|
||||
"pnpmVersion": "12.3.3"
|
||||
"pnpmVersion": "12.11.2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.98.1",
|
||||
"version": "1.99.0",
|
||||
"profile": "default"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.89.1": {
|
||||
"github/gh-aw-actions/setup@v0.89.21": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.89.1",
|
||||
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
|
||||
"version": "v0.89.21",
|
||||
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
@@ -22,6 +22,16 @@
|
||||
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
|
||||
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
|
||||
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||
@@ -32,6 +42,16 @@
|
||||
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
|
||||
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
|
||||
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||
@@ -42,20 +62,30 @@
|
||||
"digest": "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": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
|
||||
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
|
||||
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
|
||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
|
||||
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
|
||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
|
||||
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
|
||||
},
|
||||
"ghcr.io/github/gh-aw-node": {
|
||||
"image": "ghcr.io/github/gh-aw-node",
|
||||
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
||||
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
||||
},
|
||||
"ghcr.io/github/github-mcp-server:v1.12.1": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
|
||||
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
|
||||
"ghcr.io/github/github-mcp-server:v1.12.2": {
|
||||
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
|
||||
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
|
||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,19 @@ jobs:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
DRAFT: ${{ github.event.pull_request.draft }}
|
||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
run: |
|
||||
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
||||
echo "Draft or no-issue-needed; linkage check skipped."
|
||||
exit 0
|
||||
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
|
||||
echo "Issue reference found."
|
||||
else
|
||||
|
||||
@@ -3,8 +3,8 @@ name: Auto Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
|
||||
- cron: '0 4,10 * * *'
|
||||
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
|
||||
- cron: '0 10,22 * * *'
|
||||
permissions: write-all
|
||||
env:
|
||||
TAG_NAME: autobuild
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -454,7 +454,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -580,7 +580,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Backend Guardrails
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src-tauri/**"
|
||||
- ".github/workflows/backend-guardrails.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
banned-patterns:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: No Handle::refresh calls in feat
|
||||
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
|
||||
|
||||
- name: No notice_message outside core/notify
|
||||
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
|
||||
|
||||
- name: No manual update-flag chains
|
||||
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
|
||||
@@ -31,6 +31,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
||||
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
|
||||
with:
|
||||
version: v0.89.1
|
||||
version: v0.89.15
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@1.98.1
|
||||
uses: dtolnay/rust-toolchain@1.99.0
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
env:
|
||||
HUSKY: 0
|
||||
@@ -85,3 +86,73 @@ jobs:
|
||||
- name: Run Knip
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:check
|
||||
|
||||
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: node scripts/check-architecture.mjs listen emit invoke
|
||||
|
||||
contract-parity:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check contract inputs
|
||||
id: check_inputs
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: scripts/contract-inputs.json
|
||||
|
||||
- name: Skip if contract inputs unchanged
|
||||
if: steps.check_inputs.outputs.contract != 'true'
|
||||
run: echo "No contract inputs changed, skipping parity check."
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Rust Cache
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: 'v1-rust'
|
||||
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
||||
workspaces: |
|
||||
. -> target
|
||||
cache-all-crates: true
|
||||
cache-workspace-crates: true
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
|
||||
- name: Provision sidecar placeholders for tauri-build
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: |
|
||||
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
||||
touch dist/index.html
|
||||
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
||||
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
||||
done
|
||||
|
||||
- name: Check generated contract without changing the checkout
|
||||
if: steps.check_inputs.outputs.contract == 'true'
|
||||
run: pnpm gen:contract --check
|
||||
|
||||
+172
-128
File diff suppressed because one or more lines are too long
@@ -41,13 +41,17 @@ pre-agent-steps:
|
||||
' }',
|
||||
].join('\n');
|
||||
const nonBlockingQuotaBranch = [
|
||||
' let quotaErrorMsg = null;',
|
||||
' if (isQuotaExceeded) {',
|
||||
' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',
|
||||
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
|
||||
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
|
||||
' return { action: "stop", exitCode: 0 };',
|
||||
' }',
|
||||
' return { action: "stop" };',
|
||||
' quotaErrorMsg = { action: "stop" };',
|
||||
' }',
|
||||
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
|
||||
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
|
||||
' quotaErrorMsg = { action: "stop", exitCode: 0 };',
|
||||
' }',
|
||||
' if (quotaErrorMsg) {',
|
||||
' return quotaErrorMsg;',
|
||||
' }',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -446,7 +446,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.98.1'
|
||||
toolchain: '1.99.0'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Add Rust Target
|
||||
@@ -487,7 +487,7 @@ jobs:
|
||||
|
||||
- name: Add Rust Target
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
@@ -691,7 +691,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: SignPath Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
HUSKY: 0
|
||||
|
||||
jobs:
|
||||
sign-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.99.0'
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24.21.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare build resources
|
||||
run: pnpm run prebuild x86_64-pc-windows-msvc
|
||||
|
||||
- name: Build test installer
|
||||
shell: pwsh
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
run: |
|
||||
$configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json'
|
||||
'{"bundle":{"createUpdaterArtifacts":false}}' |
|
||||
Set-Content -LiteralPath $configPath -Encoding utf8
|
||||
pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath
|
||||
|
||||
- name: Upload unsigned installer
|
||||
id: unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-x64-unsigned
|
||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign with SignPath test certificate
|
||||
uses: signpath/github-action-submit-signing-request@v3
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: '5b298f4d-010d-4bae-b665-70551a58839c'
|
||||
project-slug: 'clash-verge-rev'
|
||||
signing-policy-slug: 'test-signing'
|
||||
artifact-configuration-slug: 'initial'
|
||||
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed
|
||||
|
||||
- name: Inspect installer signature
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -Path signed/*-setup.exe | ForEach-Object {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "No Authenticode signer certificate found for $($_.Name)"
|
||||
}
|
||||
$signature | Format-List Path, Status, StatusMessage, SignerCertificate
|
||||
}
|
||||
|
||||
- name: Upload signed test installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-x64-test-signed
|
||||
path: signed/*-setup.exe
|
||||
if-no-files-found: error
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||
|
||||
### 稳定机场VPN推荐
|
||||
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
Created at ${{ env.BUILDTIME }}.
|
||||
EOF
|
||||
|
||||
@@ -17,4 +17,3 @@ target
|
||||
.vfox
|
||||
.claude
|
||||
.zed
|
||||
/src-tauri/generated/widget/
|
||||
|
||||
@@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
|
||||
cargo install --force cargo-make
|
||||
fi
|
||||
|
||||
node scripts/check-contract-changes.mjs --push "$1" || exit $?
|
||||
cargo make pre-push
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
nodejs 24.21.0
|
||||
pnpm 12.4.1
|
||||
rust 1.98.1
|
||||
pnpm 12.11.2
|
||||
rust 1.99.0
|
||||
|
||||
Generated
+793
-796
File diff suppressed because it is too large
Load Diff
+19
-10
@@ -8,7 +8,6 @@ members = [
|
||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||
"crates/clash-verge-i18n",
|
||||
"crates/clash-verge-limiter",
|
||||
"crates/clash-verge-widget",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -47,6 +46,10 @@ inherits = "fast-release"
|
||||
codegen-units = 1
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[patch.crates-io]
|
||||
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
|
||||
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
|
||||
|
||||
[workspace.dependencies]
|
||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
||||
@@ -54,27 +57,32 @@ clash-verge-logging = { path = "crates/clash-verge-logging" }
|
||||
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
||||
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
||||
clash-verge-widget = { path = "crates/clash-verge-widget" }
|
||||
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
||||
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-plugin-clipboard-manager = "2.3.2"
|
||||
tauri = { version = "2.12.1" }
|
||||
tauri-plugin-clipboard-manager = "2.4.1"
|
||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||
anyhow = "1.0.104"
|
||||
chrono = "0.4.45"
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
tokio = { version = "1.53.1", features = [
|
||||
tokio = { version = "1.53.2", features = [
|
||||
"rt-multi-thread",
|
||||
"macros",
|
||||
"time",
|
||||
"sync",
|
||||
] }
|
||||
flexi_logger = "0.31.10"
|
||||
log = "0.4.33"
|
||||
log = "0.4.34"
|
||||
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"
|
||||
|
||||
smartstring = { version = "1.0.1" }
|
||||
@@ -82,7 +90,7 @@ smartstring = { version = "1.0.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_yaml_ng = { version = "0.10.0" }
|
||||
bitflags = { version = "2.13.1" }
|
||||
bitflags = { version = "2.13.2" }
|
||||
socket2 = "0.6.5"
|
||||
|
||||
# *** For Windows platform only ***
|
||||
@@ -95,7 +103,8 @@ windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects"
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_JobObjects",
|
||||
] }
|
||||
# *********************************
|
||||
|
||||
|
||||
+5
-76
@@ -1,100 +1,29 @@
|
||||
## v2.5.4
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
|
||||
## v2.5.9
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
|
||||
- 修复「首页」网站测速失败后卡片一直显示测试中的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
|
||||
|
||||
</details>
|
||||
|
||||
@@ -11,13 +11,14 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
||||
|
||||
<p align="center">
|
||||
Languages:
|
||||
<a href="./README.md">简体中文</a> ·
|
||||
<a href="./docs/README_en.md">English</a> ·
|
||||
<a href="./docs/README_es.md">Español</a> ·
|
||||
<a href="./docs/README_ru.md">Русский</a> ·
|
||||
<a href="./docs/README_fa.md">فارسی</a> ·
|
||||
<a href="./docs/README_ja.md">日本語</a> ·
|
||||
<a href="./docs/README_ko.md">한국어</a> ·
|
||||
<a href="./docs/README_fa.md">فارسی</a>
|
||||
<a href="./docs/README_pt.md">Português</a> ·
|
||||
<a href="./docs/README_ru.md">Русский</a> ·
|
||||
<a href="./README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Preview
|
||||
@@ -31,6 +32,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
||||
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
||||
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
|
||||
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
||||
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
|
||||
|
||||
#### 我应当怎样选择发行版
|
||||
|
||||
@@ -50,14 +52,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
||||
|
||||
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
||||
|
||||
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
||||
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
||||
|
||||
#### AI云边 -- 全新架构机场。
|
||||
|
||||
- 💻 多次**技术迭代后**全新亮相。
|
||||
- 🗺 全**高速稳定**正价节点。
|
||||
- 🌏 **海外团队**,不跑路
|
||||
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
|
||||
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
|
||||
- 👨🦲 团队架构师为**大厂**网络架构师
|
||||
- 💰 极致**稳定**,亲民价**价格**
|
||||
- 🌐 全面支持**流媒体及各AI访问**
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-i18n = "4.2.1"
|
||||
rust-i18n = "4.2.4"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: على وشك الخروج
|
||||
body: Clash Verge على وشك الخروج.
|
||||
appHidden:
|
||||
title: تم إخفاء التطبيق
|
||||
body: Clash Verge يعمل في الخلفية.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||
core:
|
||||
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
|
||||
launchGuard:
|
||||
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
||||
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: عام
|
||||
profiles: الملفات التعريفية
|
||||
proxies: وكلاء
|
||||
testLatency: اختبار التأخير
|
||||
testingLatency: جارٍ اختبار التأخير…
|
||||
systemProxy: وكيل النظام
|
||||
tunMode: وضع TUN
|
||||
closeAllConnections: إغلاق كل الاتصالات
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: وكيل النظام
|
||||
tun: TUN
|
||||
profile: ملف تعريفي
|
||||
widget:
|
||||
unavailable: افتح Clash Verge
|
||||
failed: فشل التحديث؛ تحقق من Clash Verge
|
||||
'on': مفعّل
|
||||
'off': معطّل
|
||||
unknown: غير معروف
|
||||
name: توجيه Clash Verge
|
||||
description: التحكم في وضع التوجيه وTUN ووكيل النظام.
|
||||
update: تحديث Clash Verge
|
||||
setting: الإعداد
|
||||
value: القيمة
|
||||
|
||||
@@ -8,8 +8,8 @@ notifications:
|
||||
body: Auf {mode} umgeschaltet.
|
||||
systemProxyToggled:
|
||||
title: Systemproxy
|
||||
'on': System proxy has been enabled.
|
||||
'off': System proxy has been disabled.
|
||||
'on': Der Systemproxy wurde aktiviert.
|
||||
'off': Der Systemproxy wurde deaktiviert.
|
||||
systemProxyFailed:
|
||||
title: Systemproxy
|
||||
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
|
||||
@@ -18,8 +18,8 @@ notifications:
|
||||
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
|
||||
tunModeToggled:
|
||||
title: TUN-Modus
|
||||
'on': TUN mode has been enabled.
|
||||
'off': TUN mode has been disabled.
|
||||
'on': Der TUN-Modus wurde aktiviert.
|
||||
'off': Der TUN-Modus wurde deaktiviert.
|
||||
lightweightModeEntered:
|
||||
title: Leichtmodus
|
||||
body: Leichtmodus aktiviert.
|
||||
@@ -29,17 +29,16 @@ notifications:
|
||||
appQuit:
|
||||
title: Beenden steht bevor
|
||||
body: Clash Verge wird gleich beendet.
|
||||
appHidden:
|
||||
title: Anwendung ausgeblendet
|
||||
body: Clash Verge läuft im Hintergrund.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
installNow: Install Now
|
||||
later: Later
|
||||
title: Clash-Verge-Update
|
||||
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
|
||||
installNow: Jetzt installieren
|
||||
later: Später
|
||||
service:
|
||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||
core:
|
||||
alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.'
|
||||
launchGuard:
|
||||
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
|
||||
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profile
|
||||
proxies: Proxy
|
||||
testLatency: Latenz testen
|
||||
testingLatency: Latenz wird getestet…
|
||||
systemProxy: Systemproxy
|
||||
tunMode: TUN-Modus
|
||||
closeAllConnections: Alle Verbindungen schließen
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Systemproxy
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Clash Verge öffnen
|
||||
failed: Aktualisierung fehlgeschlagen; Clash Verge prüfen
|
||||
'on': Ein
|
||||
'off': Aus
|
||||
unknown: Unbekannt
|
||||
name: Clash Verge Routing
|
||||
description: Routingmodus, TUN und Systemproxy steuern.
|
||||
update: Clash Verge aktualisieren
|
||||
setting: Einstellung
|
||||
value: Wert
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: About to Exit
|
||||
body: Clash Verge is about to exit.
|
||||
appHidden:
|
||||
title: Application Hidden
|
||||
body: Clash Verge is running in the background.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
|
||||
core:
|
||||
alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.'
|
||||
launchGuard:
|
||||
currentExeError: 'Unable to determine the application location: {error}'
|
||||
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profiles
|
||||
proxies: Proxies
|
||||
testLatency: Test Latency
|
||||
testingLatency: Testing latency…
|
||||
systemProxy: System Proxy
|
||||
tunMode: TUN Mode
|
||||
closeAllConnections: Close All Connections
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: System Proxy
|
||||
tun: TUN
|
||||
profile: Profile
|
||||
widget:
|
||||
unavailable: Open Clash Verge
|
||||
failed: Update failed; check Clash Verge
|
||||
'on': 'On'
|
||||
'off': 'Off'
|
||||
unknown: Unknown
|
||||
name: Clash Verge Routing
|
||||
description: Routing mode, TUN and system proxy controls.
|
||||
update: Update Clash Verge
|
||||
setting: Setting
|
||||
value: Value
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: A punto de salir
|
||||
body: Clash Verge está a punto de salir.
|
||||
appHidden:
|
||||
title: Aplicación oculta
|
||||
body: Clash Verge se está ejecutando en segundo plano.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||
core:
|
||||
alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.'
|
||||
launchGuard:
|
||||
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
|
||||
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Perfiles
|
||||
proxies: Proxies
|
||||
testLatency: Probar latencia
|
||||
testingLatency: Probando latencia…
|
||||
systemProxy: Proxy del sistema
|
||||
tunMode: Modo TUN
|
||||
closeAllConnections: Cerrar todas las conexiones
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Proxy del sistema
|
||||
tun: TUN
|
||||
profile: Perfil
|
||||
widget:
|
||||
unavailable: Abre Clash Verge
|
||||
failed: Error al actualizar; revisa Clash Verge
|
||||
'on': Activado
|
||||
'off': Desactivado
|
||||
unknown: Desconocido
|
||||
name: Enrutamiento de Clash Verge
|
||||
description: Controles del modo de enrutamiento, TUN y proxy del sistema.
|
||||
update: Actualizar Clash Verge
|
||||
setting: Ajuste
|
||||
value: Valor
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: در آستانه خروج
|
||||
body: Clash Verge در آستانه خروج است.
|
||||
appHidden:
|
||||
title: برنامه پنهان شد
|
||||
body: Clash Verge در پسزمینه در حال اجراست.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||
core:
|
||||
alreadyRunning: 'هسته شروع نمیشود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقیمانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راهاندازی مجدد کنید.'
|
||||
launchGuard:
|
||||
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
||||
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: سراسری
|
||||
profiles: پروفایلها
|
||||
proxies: پروکسیها
|
||||
testLatency: آزمایش تأخیر
|
||||
testingLatency: در حال آزمایش تأخیر…
|
||||
systemProxy: پروکسی سیستم
|
||||
tunMode: حالت TUN
|
||||
closeAllConnections: بستن همه اتصالها
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: پروکسی سیستم
|
||||
tun: TUN
|
||||
profile: پروفایل
|
||||
widget:
|
||||
unavailable: Clash Verge را باز کنید
|
||||
failed: بهروزرسانی ناموفق بود؛ Clash Verge را بررسی کنید
|
||||
'on': روشن
|
||||
'off': خاموش
|
||||
unknown: نامشخص
|
||||
name: مسیریابی Clash Verge
|
||||
description: کنترل حالت مسیریابی، TUN و پراکسی سیستم.
|
||||
update: بهروزرسانی Clash Verge
|
||||
setting: تنظیم
|
||||
value: مقدار
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Akan Keluar
|
||||
body: Clash Verge akan keluar.
|
||||
appHidden:
|
||||
title: Aplikasi Disembunyikan
|
||||
body: Clash Verge berjalan di latar belakang.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
||||
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
|
||||
core:
|
||||
alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.'
|
||||
launchGuard:
|
||||
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
||||
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Global
|
||||
profiles: Profil
|
||||
proxies: Proksi
|
||||
testLatency: Uji Latensi
|
||||
testingLatency: Menguji latensi…
|
||||
systemProxy: Proksi Sistem
|
||||
tunMode: Mode TUN
|
||||
closeAllConnections: Tutup Semua Koneksi
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Proksi Sistem
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Buka Clash Verge
|
||||
failed: Pembaruan gagal; periksa Clash Verge
|
||||
'on': Aktif
|
||||
'off': Nonaktif
|
||||
unknown: Tidak diketahui
|
||||
name: Perutean Clash Verge
|
||||
description: Kontrol mode perutean, TUN, dan proksi sistem.
|
||||
update: Perbarui Clash Verge
|
||||
setting: Pengaturan
|
||||
value: Nilai
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 終了間近
|
||||
body: Clash Verge はまもなく終了します。
|
||||
appHidden:
|
||||
title: アプリが非表示
|
||||
body: Clash Verge はバックグラウンドで実行中です。
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||
core:
|
||||
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
|
||||
launchGuard:
|
||||
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
||||
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: グローバル
|
||||
profiles: プロファイル
|
||||
proxies: プロキシ
|
||||
testLatency: 遅延テスト
|
||||
testingLatency: 遅延をテスト中…
|
||||
systemProxy: システムプロキシ
|
||||
tunMode: TUN モード
|
||||
closeAllConnections: すべての接続を閉じる
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: システムプロキシ
|
||||
tun: TUN
|
||||
profile: プロファイル
|
||||
widget:
|
||||
unavailable: Clash Verge を開いてください
|
||||
failed: 更新失敗。Clash Verge を確認してください
|
||||
'on': オン
|
||||
'off': オフ
|
||||
unknown: 不明
|
||||
name: Clash Verge ルーティング
|
||||
description: ルーティングモード、TUN、システムプロキシの操作。
|
||||
update: Clash Verge を更新
|
||||
setting: 設定
|
||||
value: 値
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 곧 종료
|
||||
body: Clash Verge가 곧 종료됩니다.
|
||||
appHidden:
|
||||
title: 앱이 숨겨짐
|
||||
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||
core:
|
||||
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
|
||||
launchGuard:
|
||||
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
||||
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: 글로벌
|
||||
profiles: 프로필
|
||||
proxies: 프록시
|
||||
testLatency: 지연 테스트
|
||||
testingLatency: 지연 테스트 중…
|
||||
systemProxy: 시스템 프록시
|
||||
tunMode: TUN 모드
|
||||
closeAllConnections: 모든 연결 닫기
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: 시스템 프록시
|
||||
tun: TUN
|
||||
profile: 프로필
|
||||
widget:
|
||||
unavailable: Clash Verge를 여세요
|
||||
failed: 업데이트 실패. Clash Verge를 확인하세요
|
||||
'on': 켜짐
|
||||
'off': 꺼짐
|
||||
unknown: 알 수 없음
|
||||
name: Clash Verge 라우팅
|
||||
description: 라우팅 모드, TUN 및 시스템 프록시 제어.
|
||||
update: Clash Verge 업데이트
|
||||
setting: 설정
|
||||
value: 값
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
_version: 1
|
||||
notifications:
|
||||
dashboardToggled:
|
||||
title: Painel
|
||||
body: A visibilidade do painel foi atualizada.
|
||||
clashModeChanged:
|
||||
title: Troca de modo
|
||||
body: Alterado para {mode}.
|
||||
systemProxyToggled:
|
||||
title: Proxy do sistema
|
||||
'on': O proxy do sistema foi ativado.
|
||||
'off': O proxy do sistema foi desativado.
|
||||
systemProxyFailed:
|
||||
title: Proxy do sistema
|
||||
body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo.
|
||||
systemProxyGuardStopped:
|
||||
title: Proteção do proxy do sistema
|
||||
body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo.
|
||||
tunModeToggled:
|
||||
title: Modo TUN
|
||||
'on': O modo TUN foi ativado.
|
||||
'off': O modo TUN foi desativado.
|
||||
lightweightModeEntered:
|
||||
title: Modo leve
|
||||
body: Modo leve ativado.
|
||||
profilesReactivated:
|
||||
title: Perfis
|
||||
body: Perfil reativado.
|
||||
appQuit:
|
||||
title: Prestes a sair
|
||||
body: O Clash Verge está prestes a sair.
|
||||
updateReady:
|
||||
title: Atualização do Clash Verge
|
||||
body: Uma nova versão (v{version}) foi baixada e está pronta para instalar.
|
||||
installNow: Instalar agora
|
||||
later: Depois
|
||||
service:
|
||||
adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador.
|
||||
adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador.
|
||||
core:
|
||||
alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.'
|
||||
launchGuard:
|
||||
currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}'
|
||||
homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}'
|
||||
translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente.
|
||||
rejected: |-
|
||||
Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos.
|
||||
|
||||
{reason}
|
||||
notInBundle: O executável não está dentro de um pacote de aplicativo.
|
||||
canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}'
|
||||
bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado.
|
||||
replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos?
|
||||
movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora?
|
||||
createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}'
|
||||
moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente.
|
||||
replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}'
|
||||
relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos.
|
||||
cancel: Cancelar
|
||||
move: Mover
|
||||
ok: OK
|
||||
tray:
|
||||
dashboard: Painel
|
||||
ruleMode: Modo regra
|
||||
globalMode: Modo global
|
||||
directMode: Modo direto
|
||||
outboundModes: Modos de saída
|
||||
rule: Regra
|
||||
direct: Direto
|
||||
global: Global
|
||||
profiles: Perfis
|
||||
proxies: Proxies
|
||||
testLatency: Test Latency
|
||||
testingLatency: Testing latency…
|
||||
systemProxy: Proxy do sistema
|
||||
tunMode: Modo TUN
|
||||
closeAllConnections: Fechar todas as conexões
|
||||
lightweightMode: Modo leve
|
||||
copyEnv: Copiar variáveis de ambiente
|
||||
confDir: Diretório de configuração
|
||||
coreDir: Diretório do núcleo
|
||||
logsDir: Diretório de logs
|
||||
openDir: Abrir diretório
|
||||
appLog: Log do aplicativo
|
||||
coreLog: Log do núcleo
|
||||
restartClash: Reiniciar o núcleo do Clash
|
||||
restartApp: Reiniciar o aplicativo
|
||||
vergeVersion: Versão do Verge
|
||||
more: Mais
|
||||
exit: Sair
|
||||
tooltip:
|
||||
systemProxy: Proxy do sistema
|
||||
tun: TUN
|
||||
profile: Perfil
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Скорый выход
|
||||
body: Clash Verge скоро завершит работу.
|
||||
appHidden:
|
||||
title: Приложение скрыто
|
||||
body: Clash Verge работает в фоновом режиме.
|
||||
updateReady:
|
||||
title: Обновление Clash Verge
|
||||
body: Новая версия (v{version}) скачана и готова к установке.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||
core:
|
||||
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
|
||||
launchGuard:
|
||||
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
||||
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Глобальный
|
||||
profiles: Профили
|
||||
proxies: Прокси
|
||||
testLatency: Проверить задержку
|
||||
testingLatency: Проверка задержки…
|
||||
systemProxy: Системный прокси
|
||||
tunMode: Режим TUN
|
||||
closeAllConnections: Закрыть все соединения
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Системный прокси
|
||||
tun: TUN
|
||||
profile: Профиль
|
||||
widget:
|
||||
unavailable: Откройте Clash Verge
|
||||
failed: Ошибка обновления; проверьте Clash Verge
|
||||
'on': Вкл.
|
||||
'off': Выкл.
|
||||
unknown: Неизвестно
|
||||
name: Маршрутизация Clash Verge
|
||||
description: Управление режимом маршрутизации, TUN и системным прокси.
|
||||
update: Обновить Clash Verge
|
||||
setting: Настройка
|
||||
value: Значение
|
||||
|
||||
@@ -8,8 +8,8 @@ notifications:
|
||||
body: '{mode} moduna geçildi.'
|
||||
systemProxyToggled:
|
||||
title: Sistem Vekil'i
|
||||
'on': System proxy has been enabled.
|
||||
'off': System proxy has been disabled.
|
||||
'on': Sistem vekili etkinleştirildi.
|
||||
'off': Sistem vekili devre dışı bırakıldı.
|
||||
systemProxyFailed:
|
||||
title: Sistem Vekili
|
||||
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
||||
@@ -18,8 +18,8 @@ notifications:
|
||||
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
||||
tunModeToggled:
|
||||
title: TUN Modu
|
||||
'on': TUN mode has been enabled.
|
||||
'off': TUN mode has been disabled.
|
||||
'on': TUN modu etkinleştirildi.
|
||||
'off': TUN modu devre dışı bırakıldı.
|
||||
lightweightModeEntered:
|
||||
title: Hafif Mod
|
||||
body: Hafif moda geçildi.
|
||||
@@ -29,17 +29,16 @@ notifications:
|
||||
appQuit:
|
||||
title: Çıkış Yapılmak Üzere
|
||||
body: Clash Verge kapanmak üzere.
|
||||
appHidden:
|
||||
title: Uygulama Gizlendi
|
||||
body: Clash Verge arka planda çalışıyor.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
installNow: Install Now
|
||||
later: Later
|
||||
title: Clash Verge Güncellemesi
|
||||
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
|
||||
installNow: Şimdi Kur
|
||||
later: Daha Sonra
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||
core:
|
||||
alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.'
|
||||
launchGuard:
|
||||
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
||||
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Küresel
|
||||
profiles: Profiller
|
||||
proxies: Vekil'ler
|
||||
testLatency: Gecikmeyi Test Et
|
||||
testingLatency: Gecikme test ediliyor…
|
||||
systemProxy: Sistem Vekil'i
|
||||
tunMode: TUN Modu
|
||||
closeAllConnections: Tüm Bağlantıları Kapat
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Sistem Vekil'i
|
||||
tun: TUN
|
||||
profile: Profil
|
||||
widget:
|
||||
unavailable: Clash Verge uygulamasını açın
|
||||
failed: Güncelleme başarısız; Clash Verge uygulamasını kontrol edin
|
||||
'on': Açık
|
||||
'off': Kapalı
|
||||
unknown: Bilinmiyor
|
||||
name: Clash Verge Yönlendirme
|
||||
description: Yönlendirme modu, TUN ve sistem vekili denetimleri.
|
||||
update: Clash Verge güncelle
|
||||
setting: Ayar
|
||||
value: Değer
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: Чыгар алдыннан
|
||||
body: Clash Verge чыгарга җыена.
|
||||
appHidden:
|
||||
title: Кушымта яшерелде
|
||||
body: Clash Verge фон режимында эшли.
|
||||
updateReady:
|
||||
title: Clash Verge Update
|
||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||
core:
|
||||
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
|
||||
launchGuard:
|
||||
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
||||
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: Глобаль
|
||||
profiles: Профильләр
|
||||
proxies: Проксилар
|
||||
testLatency: Кичектерүне тикшерү
|
||||
testingLatency: Кичектерү тикшерелә…
|
||||
systemProxy: Системалы прокси
|
||||
tunMode: TUN режимы
|
||||
closeAllConnections: Барлык тоташуларны ябу
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: Системалы прокси
|
||||
tun: TUN
|
||||
profile: Профиль
|
||||
widget:
|
||||
unavailable: Clash Verge ачыгыз
|
||||
failed: Яңарту уңышсыз; Clash Verge тикшерегез
|
||||
'on': Кабызылган
|
||||
'off': Сүндерелгән
|
||||
unknown: Билгесез
|
||||
name: Clash Verge маршрутлау
|
||||
description: Маршрутлау режимы, TUN һәм система проксие белән идарә итү.
|
||||
update: Clash Verge яңарту
|
||||
setting: Көйләү
|
||||
value: Кыйммәт
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 即将退出
|
||||
body: Clash Verge 即将退出。
|
||||
appHidden:
|
||||
title: 应用已隐藏
|
||||
body: Clash Verge 正在后台运行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||
core:
|
||||
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
|
||||
launchGuard:
|
||||
currentExeError: 无法确定应用位置:{error}
|
||||
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: 全局
|
||||
profiles: 订阅
|
||||
proxies: 代理
|
||||
testLatency: 测试延迟
|
||||
testingLatency: 正在测试延迟…
|
||||
systemProxy: 系统代理
|
||||
tunMode: TUN 模式
|
||||
closeAllConnections: 关闭所有连接
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: 系统代理
|
||||
tun: TUN
|
||||
profile: 订阅
|
||||
widget:
|
||||
unavailable: 请打开 Clash Verge
|
||||
failed: 更新失败,请检查 Clash Verge
|
||||
'on': 开启
|
||||
'off': 关闭
|
||||
unknown: 未知
|
||||
name: Clash Verge 路由
|
||||
description: 路由模式、TUN 和系统代理控制。
|
||||
update: 更新 Clash Verge
|
||||
setting: 设置
|
||||
value: 值
|
||||
|
||||
@@ -29,9 +29,6 @@ notifications:
|
||||
appQuit:
|
||||
title: 即將退出
|
||||
body: Clash Verge 即將退出。
|
||||
appHidden:
|
||||
title: 應用已隱藏
|
||||
body: Clash Verge 正在背景執行。
|
||||
updateReady:
|
||||
title: Clash Verge 更新
|
||||
body: 新版本(v{version})已下載完成,是否立即安裝?
|
||||
@@ -40,6 +37,8 @@ notifications:
|
||||
service:
|
||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
||||
core:
|
||||
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
|
||||
launchGuard:
|
||||
currentExeError: 無法判斷應用程式位置:{error}
|
||||
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
||||
@@ -71,6 +70,8 @@ tray:
|
||||
global: 全域
|
||||
profiles: 訂閱
|
||||
proxies: 代理
|
||||
testLatency: 測試延遲
|
||||
testingLatency: 正在測試延遲…
|
||||
systemProxy: 系統代理
|
||||
tunMode: 虛擬網路介面卡模式
|
||||
closeAllConnections: 關閉所有連線
|
||||
@@ -91,14 +92,3 @@ tray:
|
||||
systemProxy: 系統代理
|
||||
tun: 虛擬網路介面卡
|
||||
profile: 訂閱
|
||||
widget:
|
||||
unavailable: 請開啟 Clash Verge
|
||||
failed: 更新失敗,請檢查 Clash Verge
|
||||
'on': 開啟
|
||||
'off': 關閉
|
||||
unknown: 未知
|
||||
name: Clash Verge 路由
|
||||
description: 路由模式、TUN 和系統代理控制。
|
||||
update: 更新 Clash Verge
|
||||
setting: 設定
|
||||
value: 值
|
||||
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||
clash-verge-logging = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::utils::{country_code_to_emoji, get_local_date_string};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
|
||||
pub struct UnlockItem {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
//! Linux-only termination signal bridge.
|
||||
//!
|
||||
//! Upstream Tauri delivers session-end and termination events to the app on
|
||||
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
|
||||
//! end without any window or run event reaching the process. Linux builds
|
||||
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
|
||||
//! to the app quit flow, so system proxy and TUN cleanup still runs before
|
||||
//! the process dies. Non-Linux builds compile this crate to nothing and must
|
||||
//! not depend on it.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -5,20 +17,13 @@ use std::sync::{
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
|
||||
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ShutdownOutcome {
|
||||
Committed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
/// Serializes signal-triggered cleanups: a signal arriving while a quit
|
||||
/// attempt is still in flight is dropped instead of overlapping it.
|
||||
pub(crate) struct ShutdownLatch {
|
||||
is_cleaning_up: AtomicBool,
|
||||
}
|
||||
@@ -36,17 +41,18 @@ impl ShutdownLatch {
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn finish(&self, outcome: ShutdownOutcome) {
|
||||
if matches!(outcome, ShutdownOutcome::Canceled) {
|
||||
self.is_cleaning_up.store(false, Ordering::Release);
|
||||
}
|
||||
fn finish(&self) {
|
||||
self.is_cleaning_up.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
|
||||
/// teardown of any app-owned runtime. The handler runs once per accepted
|
||||
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => Some(rt),
|
||||
@@ -61,36 +67,22 @@ where
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(unix)]
|
||||
unix::register(f);
|
||||
|
||||
#[cfg(windows)]
|
||||
windows::register(f);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ShutdownLatch, ShutdownOutcome};
|
||||
use super::ShutdownLatch;
|
||||
|
||||
#[test]
|
||||
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
|
||||
fn finished_attempt_releases_latch_for_the_next_signal() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
assert!(!latch.try_begin());
|
||||
|
||||
latch.finish(ShutdownOutcome::Canceled);
|
||||
latch.finish();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
|
||||
let latch = ShutdownLatch::new();
|
||||
|
||||
assert!(latch.try_begin());
|
||||
latch.finish(ShutdownOutcome::Committed);
|
||||
|
||||
assert!(!latch.try_begin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
@@ -60,7 +60,8 @@ where
|
||||
}
|
||||
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
|
||||
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
f().await;
|
||||
SHUTDOWN_LATCH.finish();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use tokio::signal::windows;
|
||||
|
||||
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
|
||||
|
||||
pub fn register<F, Fut>(f: F)
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
|
||||
{
|
||||
if let Some(Some(rt)) = RUNTIME.get() {
|
||||
rt.spawn(async move {
|
||||
let mut ctrl_c = match windows::ctrl_c() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_close = match windows::ctrl_close() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut ctrl_logoff = match windows::ctrl_logoff() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let signal_name;
|
||||
tokio::select! {
|
||||
_ = ctrl_c.recv() => {
|
||||
signal_name = "Ctrl+C";
|
||||
}
|
||||
_ = ctrl_close.recv() => {
|
||||
signal_name = "Ctrl+Close";
|
||||
}
|
||||
_ = ctrl_shutdown.recv() => {
|
||||
signal_name = "Ctrl+Shutdown";
|
||||
}
|
||||
_ = ctrl_logoff.recv() => {
|
||||
signal_name = "Ctrl+Logoff";
|
||||
}
|
||||
}
|
||||
|
||||
if !SHUTDOWN_LATCH.try_begin() {
|
||||
logging!(
|
||||
info,
|
||||
Type::SystemSignal,
|
||||
"Already shutting down, ignoring repeated signal: {}",
|
||||
signal_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
|
||||
|
||||
SHUTDOWN_LATCH.finish(f().await);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logging!(
|
||||
error,
|
||||
Type::SystemSignal,
|
||||
"register shutdown signal failed, RUNTIME is not available"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Xcode per-user state and local build output
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "clash-verge-widget"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
anyhow = { workspace = true }
|
||||
libc = "0.2.189"
|
||||
log = { workspace = true }
|
||||
scopeguard = "1.2.0"
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net", "io-util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,232 +0,0 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { X509Certificate, randomBytes } from 'node:crypto'
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const crate = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const root = resolve(crate, '../..')
|
||||
const app = join(root, 'src-tauri')
|
||||
const stage = join(app, 'generated/widget')
|
||||
const run = (command, args) => {
|
||||
try {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${command} failed (${error.status}); ${command === 'security' ? 'check signing credentials' : error.stderr?.toString() || 'no diagnostic output'}`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
const config = JSON.parse(readFileSync(join(app, 'tauri.conf.json'), 'utf8'))
|
||||
const mac = JSON.parse(readFileSync(join(app, 'tauri.macos.conf.json'), 'utf8'))
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'verge-widget-build-'))
|
||||
let keychain
|
||||
try {
|
||||
if (process.platform !== 'darwin')
|
||||
throw new Error('Widget packaging requires macOS.')
|
||||
run('xcodebuild', ['-version'])
|
||||
run('xcrun', ['--find', 'appintentsmetadataprocessor'])
|
||||
const target = process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
const arch = {
|
||||
'aarch64-apple-darwin': 'arm64',
|
||||
'x86_64-apple-darwin': 'x86_64',
|
||||
'universal-apple-darwin': 'arm64 x86_64',
|
||||
}[target]
|
||||
if (!arch) throw new Error(`Unsupported widget target: ${target}`)
|
||||
if (process.env.APPLE_CERTIFICATE) {
|
||||
keychain = join(temporary, 'widget.keychain-db')
|
||||
const password = randomBytes(24).toString('hex')
|
||||
const certificate = join(temporary, 'signing.p12')
|
||||
writeFileSync(
|
||||
certificate,
|
||||
Buffer.from(process.env.APPLE_CERTIFICATE, 'base64'),
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
run('security', ['create-keychain', '-p', password, keychain])
|
||||
run('security', ['unlock-keychain', '-p', password, keychain])
|
||||
run('security', [
|
||||
'import',
|
||||
certificate,
|
||||
'-k',
|
||||
keychain,
|
||||
'-P',
|
||||
process.env.APPLE_CERTIFICATE_PASSWORD || '',
|
||||
'-T',
|
||||
'/usr/bin/codesign',
|
||||
])
|
||||
run('security', [
|
||||
'set-key-partition-list',
|
||||
'-S',
|
||||
'apple-tool:,apple:,codesign:',
|
||||
'-s',
|
||||
'-k',
|
||||
password,
|
||||
keychain,
|
||||
])
|
||||
}
|
||||
const keychainArgs = keychain ? [keychain] : []
|
||||
const identities = [
|
||||
...run('security', [
|
||||
'find-identity',
|
||||
'-v',
|
||||
'-p',
|
||||
'codesigning',
|
||||
...keychainArgs,
|
||||
]).matchAll(/\b([A-F0-9]{40}) "([^"]+)"/g),
|
||||
].map((match) => ({ hash: match[1], name: match[2] }))
|
||||
const requested =
|
||||
process.env.APPLE_SIGNING_IDENTITY || mac.bundle.macOS.signingIdentity
|
||||
if (requested === '-') {
|
||||
console.warn(
|
||||
'CLASH_VERGE_WIDGET=0: skipping widget embedding; this build has no desktop widget extension.',
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
if (!requested)
|
||||
throw new Error(
|
||||
'pnpm build on macOS needs APPLE_SIGNING_IDENTITY (or a single valid signing certificate) for widget and host signing.',
|
||||
)
|
||||
const matches = identities.filter(
|
||||
({ hash, name }) => hash === requested.toUpperCase() || name === requested,
|
||||
)
|
||||
if (matches.length !== 1)
|
||||
throw new Error(
|
||||
'Widget packaging requires one valid Apple signing identity; run pnpm build with APPLE_SIGNING_IDENTITY set.',
|
||||
)
|
||||
const identity = matches[0]
|
||||
const certificates =
|
||||
run('security', ['find-certificate', '-a', '-p', ...keychainArgs]).match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
|
||||
) || []
|
||||
const certificate = certificates
|
||||
.map((pem) => new X509Certificate(pem))
|
||||
.find((cert) => cert.fingerprint.replaceAll(':', '') === identity.hash)
|
||||
const team = certificate?.subject.match(
|
||||
/(?:^|\n)OU=([A-Z0-9]{10})(?:\n|$)/,
|
||||
)?.[1]
|
||||
if (!team) throw new Error('Selected certificate has no Apple Team ID.')
|
||||
const group = `${team}.io.vergewidget`
|
||||
const version = config.version
|
||||
mkdirSync(stage, { recursive: true })
|
||||
run('python3', [
|
||||
'-c',
|
||||
`
|
||||
import json, plistlib, sys
|
||||
from pathlib import Path
|
||||
app, stage, team, group, version = sys.argv[1:]
|
||||
stage = Path(stage)
|
||||
common = {'VergeWidgetTeam': team, 'VergeWidgetGroup': group}
|
||||
info = dict(common, CFBundleIdentifier='io.github.clash-verge-rev.clash-verge-rev.widget', CFBundleName='VergeWidget', CFBundleDevelopmentRegion='zh-Hans', CFBundleDisplayName='Clash Verge Routing', CFBundleExecutable='VergeWidget', CFBundlePackageType='XPC!', CFBundleShortVersionString=version, CFBundleVersion=version, LSMinimumSystemVersion='14.0', NSExtension={'NSExtensionPointIdentifier':'com.apple.widgetkit-extension'})
|
||||
with open(Path(app)/'packages/macos/info_merge.plist','rb') as f: host_info = plistlib.load(f)
|
||||
host_info.update(common)
|
||||
with open(Path(app)/'packages/macos/entitlements.plist','rb') as f: host_entitlements = plistlib.load(f)
|
||||
host_entitlements['com.apple.security.application-groups'] = [group]
|
||||
host_entitlements.pop('com.apple.security.inherit', None)
|
||||
ext_entitlements = {'com.apple.security.app-sandbox': True, 'com.apple.security.application-groups': [group], 'com.apple.security.network.client': True}
|
||||
for name, value in [('Widget.plist',info), ('Host.plist',host_info), ('Host.entitlements',host_entitlements), ('Widget.entitlements',ext_entitlements)]:
|
||||
with (stage/name).open('wb') as f: plistlib.dump(value,f)
|
||||
`,
|
||||
app,
|
||||
stage,
|
||||
team,
|
||||
group,
|
||||
version,
|
||||
])
|
||||
const products = join(temporary, 'products')
|
||||
run('xcodebuild', [
|
||||
'-project',
|
||||
join(crate, 'native/Widget.xcodeproj'),
|
||||
'-target',
|
||||
'VergeWidget',
|
||||
'-configuration',
|
||||
'Release',
|
||||
`CONFIGURATION_BUILD_DIR=${products}`,
|
||||
`OBJROOT=${temporary}/objects`,
|
||||
`SYMROOT=${temporary}/symbols`,
|
||||
`ARCHS=${arch}`,
|
||||
'ONLY_ACTIVE_ARCH=NO',
|
||||
'MACOSX_DEPLOYMENT_TARGET=14.0',
|
||||
'CODE_SIGNING_ALLOWED=NO',
|
||||
`WIDGET_INFO_PLIST=${stage}/Widget.plist`,
|
||||
`WIDGET_ENTITLEMENTS=${stage}/Widget.entitlements`,
|
||||
'build',
|
||||
])
|
||||
readFileSync(
|
||||
join(
|
||||
products,
|
||||
'VergeWidget.appex/Contents/Resources/Metadata.appintents/extract.actionsdata',
|
||||
),
|
||||
)
|
||||
const extension = join(products, 'VergeWidget.appex')
|
||||
if (process.env.WIDGET_PROFILE) {
|
||||
cpSync(
|
||||
process.env.WIDGET_PROFILE,
|
||||
join(extension, 'Contents/embedded.provisionprofile'),
|
||||
)
|
||||
}
|
||||
run('codesign', [
|
||||
'--force',
|
||||
'--sign',
|
||||
identity.hash,
|
||||
...(keychain ? ['--keychain', keychain] : []),
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
join(stage, 'Widget.entitlements'),
|
||||
extension,
|
||||
])
|
||||
rmSync(join(stage, 'VergeWidget.appex'), { recursive: true, force: true })
|
||||
cpSync(extension, join(stage, 'VergeWidget.appex'), { recursive: true })
|
||||
const slices = arch.split(' ').map((architecture) => {
|
||||
const output = join(temporary, `${architecture}.dylib`)
|
||||
run('xcrun', [
|
||||
'swiftc',
|
||||
'-emit-library',
|
||||
'-parse-as-library',
|
||||
'-target',
|
||||
`${architecture}-apple-macos14.0`,
|
||||
join(crate, 'native/Bridge.swift'),
|
||||
'-o',
|
||||
output,
|
||||
])
|
||||
return output
|
||||
})
|
||||
run('xcrun', [
|
||||
'lipo',
|
||||
'-create',
|
||||
...slices,
|
||||
'-output',
|
||||
join(stage, 'VergeWidgetBridge.dylib'),
|
||||
])
|
||||
console.log(
|
||||
`Prepared signed widget ${version} (${arch}); Tauri will embed it before host signing and packaging.`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Widget packaging failed:',
|
||||
error.stderr?.toString() || error.message,
|
||||
)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (keychain) {
|
||||
try {
|
||||
run('security', ['delete-keychain', keychain])
|
||||
} catch {
|
||||
console.error('Could not remove temporary widget keychain.')
|
||||
}
|
||||
}
|
||||
rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const resources = process.argv[2]
|
||||
if (!resources) throw new Error('Pass the extension resources directory.')
|
||||
const catalogs = join(root, 'crates/clash-verge-i18n/locales')
|
||||
for (const file of readdirSync(catalogs)
|
||||
.filter((file) => file.endsWith('.yml'))
|
||||
.sort()) {
|
||||
const locale = file.slice(0, -4)
|
||||
const catalog = load(readFileSync(join(catalogs, file), 'utf8'))
|
||||
const language =
|
||||
{ jp: 'ja', zh: 'zh-Hans', zhtw: 'zh-Hant' }[locale] || locale
|
||||
const strings = {
|
||||
'tray.rule': catalog.tray.rule,
|
||||
'tray.global': catalog.tray.global,
|
||||
'tray.direct': catalog.tray.direct,
|
||||
'tray.systemProxy': catalog.tray.systemProxy,
|
||||
'tray.tooltip.tun': catalog.tray.tooltip.tun,
|
||||
}
|
||||
for (const [key, value] of Object.entries(catalog.widget))
|
||||
strings[`widget.${key}`] = value
|
||||
const directory = join(resources, `${language}.lproj`)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(
|
||||
join(directory, 'Localizable.strings'),
|
||||
Object.entries(strings)
|
||||
.map(
|
||||
([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)};`,
|
||||
)
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Security
|
||||
import WidgetKit
|
||||
|
||||
let widgetKind = "VergeRoutingWidget"
|
||||
let hostID = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
func socketPath() -> String? {
|
||||
guard let group = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetGroup") as? String else { return nil }
|
||||
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else {
|
||||
return nil
|
||||
}
|
||||
return container.appendingPathComponent("verge-widget.sock").path
|
||||
}
|
||||
|
||||
func authenticPeer(_ descriptor: Int32, identifier: String) -> Bool {
|
||||
var token = audit_token_t()
|
||||
var size = socklen_t(MemoryLayout.size(ofValue: token))
|
||||
guard getsockopt(descriptor, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &size) == 0,
|
||||
size == MemoryLayout.size(ofValue: token) else { return false }
|
||||
let data = withUnsafeBytes(of: token) { Data($0) }
|
||||
var peer: SecCode?
|
||||
let attributes = [kSecGuestAttributeAudit: data] as CFDictionary
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &peer) == errSecSuccess,
|
||||
let peer,
|
||||
let team = Bundle.main.object(forInfoDictionaryKey: "VergeWidgetTeam") as? String,
|
||||
team.range(of: "^[A-Z0-9]{10}$", options: .regularExpression) != nil else { return false }
|
||||
var requirement: SecRequirement?
|
||||
let expression = """
|
||||
anchor apple generic and certificate leaf[subject.OU] = "\(team)" and identifier "\(identifier)"
|
||||
"""
|
||||
guard SecRequirementCreateWithString(expression as CFString, [], &requirement) == errSecSuccess,
|
||||
let requirement else { return false }
|
||||
return SecCodeCheckValidity(peer, [], requirement) == errSecSuccess
|
||||
}
|
||||
|
||||
#if !WIDGET_EXTENSION
|
||||
@_cdecl("verge_widget_path")
|
||||
public func vergeWidgetPath() -> UnsafeMutablePointer<CChar>? {
|
||||
socketPath().flatMap { strdup($0) }
|
||||
}
|
||||
|
||||
@_cdecl("verge_widget_authenticate")
|
||||
public func vergeWidgetAuthenticate(_ descriptor: Int32) -> Bool {
|
||||
authenticPeer(descriptor, identifier: hostID + ".widget")
|
||||
}
|
||||
|
||||
@_cdecl("verge_widget_reload")
|
||||
public func vergeWidgetReload() {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
|
||||
}
|
||||
#endif
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WidgetPreviewHost: App {
|
||||
var body: some Scene {
|
||||
WindowGroup("Widget Preview Host") {
|
||||
Text("Use the StylePreviews.swift Canvas to preview routing controls.")
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key><string>VergeWidget</string>
|
||||
<key>CFBundlePackageType</key><string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>CFBundleDevelopmentRegion</key><string>zh-Hans</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>NSExtension</key><dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,201 +0,0 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import Foundation
|
||||
|
||||
struct RoutingView: View {
|
||||
let entry: Entry
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
#if DEBUG
|
||||
var previewFamily: WidgetFamily?
|
||||
private var family: WidgetFamily { previewFamily ?? widgetFamily }
|
||||
#else
|
||||
private var family: WidgetFamily { widgetFamily }
|
||||
#endif
|
||||
@Environment(\.widgetRenderingMode) private var systemRenderingMode
|
||||
#if DEBUG
|
||||
var previewRenderingMode: WidgetRenderingMode?
|
||||
var previewMaterial = "automatic"
|
||||
var previewIncreaseContrast = false
|
||||
private var renderingMode: WidgetRenderingMode { previewRenderingMode ?? systemRenderingMode }
|
||||
#else
|
||||
private var renderingMode: WidgetRenderingMode { systemRenderingMode }
|
||||
#endif
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.colorSchemeContrast) private var systemContrast
|
||||
#if DEBUG
|
||||
private var contrast: ColorSchemeContrast { previewIncreaseContrast ? .increased : systemContrast }
|
||||
#else
|
||||
private var contrast: ColorSchemeContrast { systemContrast }
|
||||
#endif
|
||||
@Environment(\.accessibilityReduceMotion) private var systemReduceMotion
|
||||
@Environment(\.accessibilityReduceTransparency) private var systemReduceTransparency
|
||||
#if DEBUG
|
||||
var previewReduceMotion: Bool?
|
||||
var previewReduceTransparency: Bool?
|
||||
private var reduceMotion: Bool { previewReduceMotion ?? systemReduceMotion }
|
||||
private var reduceTransparency: Bool { previewReduceTransparency ?? systemReduceTransparency }
|
||||
#else
|
||||
private var reduceMotion: Bool { systemReduceMotion }
|
||||
private var reduceTransparency: Bool { systemReduceTransparency }
|
||||
#endif
|
||||
private func text(_ key: String) -> String { localized(key, language: entry.status.language) }
|
||||
private let modes = ["rule", "global", "direct"]
|
||||
private var ink: Color { renderingMode == .fullColor ? (colorScheme == .dark ? .white : .black) : .primary }
|
||||
private var secondaryInk: Color { ink.opacity(contrast == .increased ? 0.8 : 0.6) }
|
||||
@ViewBuilder private var selectionBackground: some View {
|
||||
#if DEBUG
|
||||
if previewMaterial == "thin" && renderingMode == .fullColor && !reduceTransparency {
|
||||
Capsule().fill(.thinMaterial)
|
||||
} else {
|
||||
automaticSelectionBackground
|
||||
}
|
||||
#else
|
||||
automaticSelectionBackground
|
||||
#endif
|
||||
}
|
||||
@ViewBuilder private var automaticSelectionBackground: some View {
|
||||
if renderingMode != .fullColor {
|
||||
Capsule().strokeBorder(ink.opacity(0.65), lineWidth: 1)
|
||||
} else if reduceTransparency {
|
||||
Capsule().fill(ink)
|
||||
} else if #available(macOS 26.0, *) {
|
||||
Capsule().fill(.clear)
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
} else {
|
||||
Capsule().fill(.thinMaterial)
|
||||
}
|
||||
}
|
||||
private func modeInk(selected: Bool) -> Color {
|
||||
guard selected && reduceTransparency && renderingMode == .fullColor else {
|
||||
return selected ? ink : secondaryInk
|
||||
}
|
||||
return colorScheme == .dark ? .black : .white
|
||||
}
|
||||
private var locale: Locale {
|
||||
let aliases = ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"]
|
||||
let identifier = entry.status.language.map { aliases[$0] ?? $0 } ?? Locale.current.identifier
|
||||
return Locale(identifier: identifier)
|
||||
}
|
||||
private var rtl: Bool { locale.language.characterDirection == .rightToLeft }
|
||||
|
||||
func control(_ title: String, action: String, value: Bool?, icon: String) -> some View {
|
||||
let compact = family == .systemSmall
|
||||
return Button(intent: SetRouting(action, value == true ? "false" : "true")) {
|
||||
let label = HStack(spacing: 4) {
|
||||
if !compact { Image(systemName: icon).font(.caption2) }
|
||||
Text(title).font(.caption)
|
||||
.lineLimit(compact ? 2 : 1)
|
||||
.frame(height: compact ? 30 : nil, alignment: .topLeading)
|
||||
}
|
||||
let toggle = ZStack(alignment: .leading) {
|
||||
Capsule().fill(ink.opacity(value == true ? 0.28 : 0.08))
|
||||
Circle().fill(ink)
|
||||
.frame(width: 13, height: 13)
|
||||
.offset(x: value.map { $0 ? 19.0 : 4.0 } ?? 11.5)
|
||||
}
|
||||
.frame(width: 36, height: 22)
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
if compact {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
label
|
||||
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
label
|
||||
Spacer(minLength: 0)
|
||||
if value == nil { Text(text("widget.unknown")).font(.caption2) } else { toggle }
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(secondaryInk)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.disabled(value == nil)
|
||||
.transaction { $0.animation = nil; $0.disablesAnimations = true }
|
||||
.invalidatableContent()
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(text(value.map { $0 ? "widget.on" : "widget.off" } ?? "widget.unknown"))
|
||||
}
|
||||
|
||||
@ViewBuilder private var containerMaterial: some View {
|
||||
if reduceTransparency {
|
||||
Rectangle().fill(.background)
|
||||
} else {
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
#if DEBUG
|
||||
if previewFamily != nil {
|
||||
content.padding(16)
|
||||
.frame(width: family == .systemSmall ? 170 : 360, height: 170)
|
||||
.background { containerMaterial }
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
} else {
|
||||
content.containerBackground(for: .widget) { containerMaterial }
|
||||
}
|
||||
#else
|
||||
content.containerBackground(for: .widget) { containerMaterial }
|
||||
#endif
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
GeometryReader { geometry in
|
||||
let width = (geometry.size.width - 4) / 3
|
||||
let orderedModes = rtl ? Array(modes.reversed()) : modes
|
||||
let index = orderedModes.firstIndex(of: entry.status.mode ?? "")
|
||||
ZStack(alignment: .leading) {
|
||||
selectionBackground
|
||||
.frame(width: width - 2, height: 26)
|
||||
.offset(x: 3 + CGFloat(index ?? 0) * width)
|
||||
.opacity(index == nil ? 0 : 1)
|
||||
.allowsHitTesting(false)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(orderedModes, id: \.self) { mode in
|
||||
let selected = entry.status.mode == mode
|
||||
let label = Text(text("tray." + mode))
|
||||
.font(selected ? .footnote.weight(.semibold) : .footnote)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 30)
|
||||
.foregroundStyle(modeInk(selected: selected))
|
||||
.contentTransition(reduceMotion ? .identity : .opacity)
|
||||
.contentShape(Rectangle())
|
||||
Button(intent: SetRouting("mode", mode)) { label }
|
||||
.disabled(index == nil)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
.zIndex(1)
|
||||
}
|
||||
.invalidatableContent()
|
||||
.environment(\.layoutDirection, .leftToRight)
|
||||
}
|
||||
.frame(height: 30)
|
||||
HStack(spacing: 8) {
|
||||
control(text("tray.tooltip.tun"), action: "tun", value: entry.status.tun, icon: "network")
|
||||
control(
|
||||
text("tray.systemProxy"), action: "proxy",
|
||||
value: entry.status.proxy, icon: "shield.lefthalf.filled"
|
||||
)
|
||||
}
|
||||
if let error = entry.status.error {
|
||||
Label {
|
||||
Text(text(error)).font(.caption2).lineLimit(2)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
|
||||
}
|
||||
.foregroundStyle(secondaryInk)
|
||||
}
|
||||
}
|
||||
.environment(\.locale, locale)
|
||||
.environment(\.layoutDirection, rtl ? .rightToLeft : .leftToRight)
|
||||
.transaction { $0.animation = nil }
|
||||
.buttonStyle(.plain)
|
||||
.widgetURL(URL(string: "clash-verge://show"))
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
#if DEBUG && PREVIEW_HOST
|
||||
import UniformTypeIdentifiers
|
||||
import AppKit
|
||||
private struct RoutingStylePreview: View {
|
||||
@State var medium = false
|
||||
@State var dark = false
|
||||
@State var language = "en"
|
||||
@State var mode = "rule"
|
||||
@State var tun = "on"
|
||||
@State var proxy = "off"
|
||||
@State var error = "none"
|
||||
@State var reduceTransparency = false
|
||||
@State var reduceMotion = false
|
||||
@State private var backdrop = "gradient"
|
||||
@State private var material = "automatic"
|
||||
@State private var rendering = "automatic"
|
||||
@State private var increaseContrast = false
|
||||
@State private var importingWallpaper = false
|
||||
@State private var wallpaper: NSImage?
|
||||
@State private var wallpaperName = ""
|
||||
@State private var wallpaperError: String?
|
||||
|
||||
private var renderingOverride: WidgetRenderingMode? {
|
||||
switch rendering {
|
||||
case "fullColor": .fullColor
|
||||
case "accented": .accented
|
||||
case "vibrant": .vibrant
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
private func importWallpaper(_ result: Result<URL, Error>) {
|
||||
do {
|
||||
let url = try result.get()
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let image = NSImage(data: data) else {
|
||||
wallpaperError = "The selected file is not a readable image."
|
||||
return
|
||||
}
|
||||
wallpaper = image
|
||||
wallpaperName = url.lastPathComponent
|
||||
wallpaperError = nil
|
||||
backdrop = "wallpaper"
|
||||
} catch {
|
||||
let failure = error as NSError
|
||||
if failure.domain != NSCocoaErrorDomain || failure.code != NSUserCancelledError {
|
||||
wallpaperError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func state(_ value: String) -> Bool? {
|
||||
value == "unknown" ? nil : value == "on"
|
||||
}
|
||||
private var sample: Status {
|
||||
Status(mode: mode == "unknown" ? nil : mode, tun: state(tun), proxy: state(proxy),
|
||||
error: error == "none" ? nil : "widget." + error, language: language)
|
||||
}
|
||||
|
||||
@ViewBuilder private var background: some View {
|
||||
switch backdrop {
|
||||
case "wallpaper":
|
||||
if let wallpaper {
|
||||
GeometryReader { geometry in
|
||||
Image(nsImage: wallpaper).resizable().scaledToFill()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height).clipped()
|
||||
}
|
||||
}
|
||||
case "light": Color(white: 0.92)
|
||||
case "dark": Color(white: 0.1)
|
||||
case "pattern":
|
||||
Canvas { context, _ in
|
||||
for row in 0..<8 {
|
||||
for column in 0..<14 {
|
||||
let rect = CGRect(x: CGFloat(column) * 32, y: CGFloat(row) * 32, width: 32, height: 32)
|
||||
context.fill(Path(rect), with: .color((row + column).isMultiple(of: 2) ? .white : .black))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
LinearGradient(colors: [.blue, .purple, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
background
|
||||
RoutingView(
|
||||
entry: Entry(date: .now, status: sample),
|
||||
previewFamily: medium ? .systemMedium : .systemSmall,
|
||||
previewRenderingMode: renderingOverride,
|
||||
previewMaterial: material,
|
||||
previewIncreaseContrast: increaseContrast,
|
||||
previewReduceMotion: reduceMotion,
|
||||
previewReduceTransparency: reduceTransparency
|
||||
)
|
||||
.environment(\.colorScheme, dark ? .dark : .light)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: 218)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Toggle("Medium", isOn: $medium)
|
||||
Toggle("Dark", isOn: $dark)
|
||||
}
|
||||
Picker("Background", selection: $backdrop) {
|
||||
Text("Light").tag("light")
|
||||
Text("Dark").tag("dark")
|
||||
Text("Gradient").tag("gradient")
|
||||
Text("Contrast pattern").tag("pattern")
|
||||
if wallpaper != nil { Text("Selected wallpaper").tag("wallpaper") }
|
||||
}
|
||||
HStack {
|
||||
Button("Choose wallpaper…") { importingWallpaper = true }
|
||||
if wallpaper != nil {
|
||||
Text(wallpaperName).lineLimit(1).truncationMode(.middle)
|
||||
Button("Clear") {
|
||||
wallpaper = nil; wallpaperName = ""; wallpaperError = nil
|
||||
if backdrop == "wallpaper" { backdrop = "gradient" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let wallpaperError { Text(wallpaperError).font(.caption).foregroundStyle(.red) }
|
||||
Picker("Selected material", selection: $material) {
|
||||
Text("Automatic").tag("automatic")
|
||||
Text("Thin material").tag("thin")
|
||||
if #available(macOS 26.0, *) { Text("Liquid Glass").tag("glass") }
|
||||
}
|
||||
Text("Selected material applies only in full-color rendering with Reduce transparency off.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Picker("Rendering input (approximate)", selection: $rendering) {
|
||||
Text("Automatic").tag("automatic")
|
||||
Text("Full color").tag("fullColor")
|
||||
Text("Accented").tag("accented")
|
||||
Text("Vibrant").tag("vibrant")
|
||||
}
|
||||
Toggle("Increase contrast (approximate)", isOn: $increaseContrast)
|
||||
Picker("Language", selection: $language) {
|
||||
Text("English").tag("en")
|
||||
Text("简体中文").tag("zh")
|
||||
}
|
||||
Picker("Mode", selection: $mode) {
|
||||
Text("Rule").tag("rule")
|
||||
Text("Global").tag("global")
|
||||
Text("Direct").tag("direct")
|
||||
Text("Unknown").tag("unknown")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
HStack {
|
||||
Picker("TUN", selection: $tun) {
|
||||
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
|
||||
}
|
||||
Picker("Proxy", selection: $proxy) {
|
||||
Text("On").tag("on"); Text("Off").tag("off"); Text("Unknown").tag("unknown")
|
||||
}
|
||||
}
|
||||
Picker("Error", selection: $error) {
|
||||
Text("None").tag("none")
|
||||
Text("Offline").tag("unavailable")
|
||||
Text("Failed").tag("failed")
|
||||
}
|
||||
Toggle("Reduce transparency", isOn: $reduceTransparency)
|
||||
Toggle("Reduce motion", isOn: $reduceMotion)
|
||||
Text(
|
||||
"Style only. Controls change sample data; no proxy requests. "
|
||||
+ "Desktop WidgetKit compositing is not simulated."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 440, height: 570)
|
||||
.fileImporter(isPresented: $importingWallpaper, allowedContentTypes: [.image], onCompletion: importWallpaper)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Style control panel") {
|
||||
RoutingStylePreview()
|
||||
}
|
||||
|
||||
#Preview("Medium · 中文 · Dark") {
|
||||
RoutingStylePreview(medium: true, dark: true, language: "zh", mode: "global")
|
||||
}
|
||||
|
||||
#Preview("Small · English · Reduced transparency") {
|
||||
RoutingStylePreview(language: "en", mode: "direct", reduceTransparency: true)
|
||||
}
|
||||
|
||||
#Preview("中文 · Unknown · Offline") {
|
||||
RoutingStylePreview(
|
||||
medium: true, dark: true, language: "zh", mode: "unknown",
|
||||
tun: "unknown", proxy: "unknown", error: "unavailable"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
@@ -1,65 +0,0 @@
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import Foundation
|
||||
|
||||
struct SetRouting: AppIntent {
|
||||
static let title: LocalizedStringResource = "widget.update"
|
||||
static let openAppWhenRun = false
|
||||
@Parameter(title: "widget.setting") var action: String
|
||||
@Parameter(title: "widget.value") var value: String
|
||||
init() {}
|
||||
init(_ action: String, _ value: String) { self.action = action; self.value = value }
|
||||
func perform() async throws -> some IntentResult {
|
||||
let status = await Task.detached { exchange(action: action, value: value.isEmpty ? nil : value) }.value
|
||||
if let error = status.error {
|
||||
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
|
||||
throw WidgetFailure(message: localized(error, language: status.language))
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetFailure: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
struct Entry: TimelineEntry {
|
||||
var date: Date
|
||||
var status: Status
|
||||
}
|
||||
|
||||
struct Provider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> Entry {
|
||||
Entry(date: .now, status: Status(error: "widget.unavailable"))
|
||||
}
|
||||
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(Entry(date: .now, status: Status(mode: "rule", tun: true, proxy: false)))
|
||||
} else {
|
||||
getTimeline(in: context) { completion($0.entries[0]) }
|
||||
}
|
||||
}
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
|
||||
DispatchQueue.global().async {
|
||||
let status = exchange()
|
||||
let now = Date()
|
||||
let unavailable = status.mode == nil || status.tun == nil || status.proxy == nil
|
||||
let refresh = now.addingTimeInterval(unavailable ? 60 : 900)
|
||||
completion(Timeline(entries: [Entry(date: now, status: status)], policy: .after(refresh)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !PREVIEW_HOST
|
||||
@main
|
||||
#endif
|
||||
struct RoutingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: widgetKind, provider: Provider()) { RoutingView(entry: $0) }
|
||||
.configurationDisplayName("widget.name")
|
||||
.description("widget.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
A00000000000000000000001 = {isa = PBXProject; buildConfigurationList = A00000000000000000000002; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; knownRegions = (en, Base); mainGroup = A00000000000000000000003; productRefGroup = A00000000000000000000004; projectDirPath = ""; projectRoot = ""; targets = (A00000000000000000000005, A00000000000000000000020); };
|
||||
A00000000000000000000002 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000006, A00000000000000000000010); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A00000000000000000000003 = {isa = PBXGroup; children = (A00000000000000000000007, A00000000000000000000008, A00000000000000000000021, A00000000000000000000030, A00000000000000000000040, A00000000000000000000041, A00000000000000000000004); sourceTree = "<group>"; };
|
||||
A00000000000000000000004 = {isa = PBXGroup; children = (A00000000000000000000009, A00000000000000000000022); name = Products; sourceTree = "<group>"; };
|
||||
A00000000000000000000005 = {isa = PBXNativeTarget; buildConfigurationList = A0000000000000000000000A; buildPhases = (A0000000000000000000000B, A0000000000000000000000C, A00000000000000000000012); buildRules = (); dependencies = (); name = VergeWidget; productName = VergeWidget; productReference = A00000000000000000000009; productType = "com.apple.product-type.app-extension"; };
|
||||
A00000000000000000000006 = {isa = XCBuildConfiguration; name = Release; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
|
||||
A00000000000000000000007 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000009 = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = VergeWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A0000000000000000000000A = {isa = XCConfigurationList; buildConfigurations = (A0000000000000000000000D, A00000000000000000000011); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A0000000000000000000000B = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A0000000000000000000000E, A0000000000000000000000F, A00000000000000000000042, A00000000000000000000043); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A0000000000000000000000C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A0000000000000000000000D = {isa = XCBuildConfiguration; name = Release; buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
|
||||
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
|
||||
WIDGET_ENTITLEMENTS = "";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.widget";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = WIDGET_EXTENSION;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
}; };
|
||||
A00000000000000000000011 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "$(WIDGET_ENTITLEMENTS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "$(WIDGET_INFO_PLIST)";
|
||||
WIDGET_INFO_PLIST = "$(SRCROOT)/Preview.plist";
|
||||
WIDGET_ENTITLEMENTS = "";
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks");
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview.widget";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "WIDGET_EXTENSION DEBUG";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
}; };
|
||||
A0000000000000000000000E = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
|
||||
A0000000000000000000000F = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
|
||||
A00000000000000000000010 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_VERSION = 5.0; }; };
|
||||
A00000000000000000000012 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
|
||||
A00000000000000000000020 = {isa = PBXNativeTarget; buildConfigurationList = A00000000000000000000023; buildPhases = (A00000000000000000000024, A00000000000000000000025, A00000000000000000000026, A00000000000000000000034); buildRules = (); dependencies = (A00000000000000000000027); name = PreviewHost; productName = PreviewHost; productReference = A00000000000000000000022; productType = "com.apple.product-type.application"; };
|
||||
A00000000000000000000021 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Host.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000022 = {isa = PBXFileReference; explicitFileType = wrapper.application; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A00000000000000000000023 = {isa = XCConfigurationList; buildConfigurations = (A00000000000000000000028); defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; };
|
||||
A00000000000000000000024 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000029, A00000000000000000000031, A00000000000000000000032, A00000000000000000000033, A00000000000000000000044, A00000000000000000000045); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000025 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000026 = {isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = (A0000000000000000000002A); name = "Embed Widget"; runOnlyForDeploymentPostprocessing = 0; };
|
||||
A00000000000000000000027 = {isa = PBXTargetDependency; target = A00000000000000000000005; targetProxy = A0000000000000000000002B; };
|
||||
A00000000000000000000028 = {isa = XCBuildConfiguration; name = Debug; buildSettings = {CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Widget Preview Host"; PRODUCT_BUNDLE_IDENTIFIER = "io.github.clash-verge-rev.clash-verge-rev.preview"; PRODUCT_NAME = "$(TARGET_NAME)"; MARKETING_VERSION = 1.0; CURRENT_PROJECT_VERSION = 1; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG PREVIEW_HOST WIDGET_EXTENSION"; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_PREVIEWS = YES; LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/../Frameworks"); }; };
|
||||
A00000000000000000000029 = {isa = PBXBuildFile; fileRef = A00000000000000000000021; };
|
||||
A0000000000000000000002A = {isa = PBXBuildFile; fileRef = A00000000000000000000009; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy); }; };
|
||||
A0000000000000000000002B = {isa = PBXContainerItemProxy; containerPortal = A00000000000000000000001; proxyType = 1; remoteGlobalIDString = A00000000000000000000005; remoteInfo = VergeWidget; };
|
||||
A00000000000000000000030 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StylePreviews.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000031 = {isa = PBXBuildFile; fileRef = A00000000000000000000007; };
|
||||
A00000000000000000000032 = {isa = PBXBuildFile; fileRef = A00000000000000000000008; };
|
||||
A00000000000000000000033 = {isa = PBXBuildFile; fileRef = A00000000000000000000030; };
|
||||
A00000000000000000000034 = {isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = (); inputPaths = (); outputPaths = (); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; alwaysOutOfDate = 1; shellScript = "export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH\nnode \"$SRCROOT/../build/localize.mjs\" \"$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\"\n"; };
|
||||
A00000000000000000000040 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetClient.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingView.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000042 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
|
||||
A00000000000000000000043 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
|
||||
A00000000000000000000044 = {isa = PBXBuildFile; fileRef = A00000000000000000000040; };
|
||||
A00000000000000000000045 = {isa = PBXBuildFile; fileRef = A00000000000000000000041; };
|
||||
};
|
||||
rootObject = A00000000000000000000001;
|
||||
}
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "NO">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Debug"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A00000000000000000000020"
|
||||
BuildableName = "PreviewHost.app"
|
||||
BlueprintName = "PreviewHost"
|
||||
ReferencedContainer = "container:Widget.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,104 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct Status: Codable {
|
||||
var mode: String?
|
||||
var tun: Bool?
|
||||
var proxy: Bool?
|
||||
var error: String?
|
||||
var language: String?
|
||||
}
|
||||
|
||||
func localized(_ key: String, language: String? = nil) -> String {
|
||||
let code = language.map { ["jp": "ja", "zh": "zh-Hans", "zhtw": "zh-Hant"][$0] ?? $0 }
|
||||
let path = code.flatMap { Bundle.main.path(forResource: $0, ofType: "lproj") }
|
||||
let bundle = path.flatMap(Bundle.init(path:)) ?? .main
|
||||
return bundle.localizedString(forKey: key, value: nil, table: nil)
|
||||
}
|
||||
|
||||
private func connectSocket(_ path: String) -> Int32? {
|
||||
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard descriptor >= 0 else { return nil }
|
||||
var timeout = timeval(tv_sec: 8, tv_usec: 0)
|
||||
var noSignal: Int32 = 1
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size(ofValue: timeout)))
|
||||
setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size(ofValue: noSignal)))
|
||||
var address = sockaddr_un()
|
||||
address.sun_family = sa_family_t(AF_UNIX)
|
||||
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
|
||||
path.withCString { source in
|
||||
withUnsafeMutableBytes(of: &address.sun_path) { bytes in
|
||||
bytes.copyMemory(from: UnsafeRawBufferPointer(start: source, count: path.utf8.count + 1))
|
||||
}
|
||||
}
|
||||
let connected = withUnsafePointer(to: &address) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
connect(descriptor, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard connected == 0 else {
|
||||
close(descriptor)
|
||||
return nil
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func sendRequest(_ data: Data, descriptor: Int32) -> Bool {
|
||||
data.withUnsafeBytes { bytes in
|
||||
var offset = 0
|
||||
while offset < bytes.count {
|
||||
let count = write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
|
||||
if count < 0 && errno == EINTR { continue }
|
||||
guard count > 0 else { return false }
|
||||
offset += count
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func readResponse(descriptor: Int32) -> Status? {
|
||||
var response = Data(count: 4096)
|
||||
var received = 0
|
||||
while received < 4096 {
|
||||
let count = response.withUnsafeMutableBytes { bytes in
|
||||
read(descriptor, bytes.baseAddress!.advanced(by: received), bytes.count - received)
|
||||
}
|
||||
if count < 0 && errno == EINTR { continue }
|
||||
guard count > 0 else { return nil }
|
||||
let end = received + count
|
||||
if let newline = response[received..<end].firstIndex(of: 10) {
|
||||
response.count = newline
|
||||
return try? JSONDecoder().decode(Status.self, from: response)
|
||||
}
|
||||
received = end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exchange(action: String = "status", value: String? = nil) -> Status {
|
||||
#if PREVIEW_HOST
|
||||
return Status(mode: "rule", tun: true, proxy: false, language: "en")
|
||||
#else
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
|
||||
return Status(mode: "rule", tun: true, proxy: false, language: "en")
|
||||
}
|
||||
#endif
|
||||
func unavailable(_ key: String) -> Status { Status(error: key) }
|
||||
guard let path = socketPath(), path.utf8.count < 104 else {
|
||||
return unavailable("widget.unavailable")
|
||||
}
|
||||
guard let descriptor = connectSocket(path) else { return unavailable("widget.unavailable") }
|
||||
defer { close(descriptor) }
|
||||
guard authenticPeer(descriptor, identifier: hostID) else { return unavailable("widget.unavailable") }
|
||||
var request = ["action": action]
|
||||
if let value { request["value"] = value }
|
||||
guard var data = try? JSONSerialization.data(withJSONObject: request) else {
|
||||
return unavailable("widget.failed")
|
||||
}
|
||||
data.append(10)
|
||||
guard sendRequest(data, descriptor: descriptor) else { return unavailable("widget.failed") }
|
||||
return readResponse(descriptor: descriptor) ?? unavailable("widget.failed")
|
||||
#endif
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::future::Future;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod server;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use server::{reload, start};
|
||||
|
||||
#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Mode {
|
||||
Rule,
|
||||
Global,
|
||||
Direct,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rule => "rule",
|
||||
Self::Global => "global",
|
||||
Self::Direct => "direct",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Action {
|
||||
Mode(Mode),
|
||||
Tun(bool),
|
||||
Proxy(bool),
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct Status {
|
||||
pub mode: Option<Mode>,
|
||||
pub tun: Option<bool>,
|
||||
pub proxy: Option<bool>,
|
||||
pub error: Option<String>,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn confirms(&self, action: Action) -> bool {
|
||||
match action {
|
||||
Action::Mode(value) => self.mode == Some(value),
|
||||
Action::Tun(value) => self.tun == Some(value),
|
||||
Action::Proxy(value) => self.proxy == Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Backend: Send + Sync + 'static {
|
||||
fn snapshot(&self) -> impl Future<Output = Status> + Send;
|
||||
fn language(&self) -> impl Future<Output = String> + Send;
|
||||
fn apply(&self, action: Action) -> impl Future<Output = Result<(), String>> + Send;
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
use crate::{Action, Backend, Mode, Status};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
ffi::{CStr, CString},
|
||||
fs, mem,
|
||||
os::fd::AsRawFd as _,
|
||||
os::unix::fs::PermissionsExt as _,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader},
|
||||
net::UnixListener,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
struct Native {
|
||||
path: unsafe extern "C" fn() -> *mut libc::c_char,
|
||||
authenticate: unsafe extern "C" fn(i32) -> bool,
|
||||
reload: unsafe extern "C" fn(),
|
||||
}
|
||||
static NATIVE: OnceLock<Native> = OnceLock::new();
|
||||
|
||||
fn load_native() -> anyhow::Result<Native> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let contents = executable
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow::anyhow!("Not an app bundle"))?;
|
||||
let path = CString::new(
|
||||
contents
|
||||
.join("Frameworks/VergeWidgetBridge.dylib")
|
||||
.as_os_str()
|
||||
.as_encoded_bytes(),
|
||||
)?;
|
||||
unsafe {
|
||||
let library = libc::dlopen(path.as_ptr(), libc::RTLD_NOW | libc::RTLD_LOCAL);
|
||||
anyhow::ensure!(!library.is_null(), "Widget bridge unavailable");
|
||||
let path = libc::dlsym(library, c"verge_widget_path".as_ptr());
|
||||
let authenticate = libc::dlsym(library, c"verge_widget_authenticate".as_ptr());
|
||||
let reload = libc::dlsym(library, c"verge_widget_reload".as_ptr());
|
||||
anyhow::ensure!(
|
||||
!path.is_null() && !authenticate.is_null() && !reload.is_null(),
|
||||
"Invalid widget bridge"
|
||||
);
|
||||
Ok(Native {
|
||||
path: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn() -> *mut libc::c_char>(path),
|
||||
authenticate: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn(i32) -> bool>(authenticate),
|
||||
reload: mem::transmute::<*mut libc::c_void, unsafe extern "C" fn()>(reload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static MUTATING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn reload() {
|
||||
if MUTATING.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(native) = NATIVE.get() {
|
||||
unsafe { (native.reload)() };
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Request<'a> {
|
||||
#[serde(borrow)]
|
||||
action: Cow<'a, str>,
|
||||
#[serde(borrow)]
|
||||
value: Option<RequestValue<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(transparent)]
|
||||
struct RequestValue<'a>(#[serde(borrow)] Cow<'a, str>);
|
||||
|
||||
impl Request<'_> {
|
||||
fn action(&self) -> Result<Option<Action>, &'static str> {
|
||||
match (self.action.as_ref(), self.value.as_ref().map(|value| value.0.as_ref())) {
|
||||
("status", None) => Ok(None),
|
||||
("mode", Some("rule")) => Ok(Some(Action::Mode(Mode::Rule))),
|
||||
("mode", Some("global")) => Ok(Some(Action::Mode(Mode::Global))),
|
||||
("mode", Some("direct")) => Ok(Some(Action::Mode(Mode::Direct))),
|
||||
("tun", Some(value @ ("true" | "false"))) => Ok(Some(Action::Tun(value == "true"))),
|
||||
("proxy", Some(value @ ("true" | "false"))) => Ok(Some(Action::Proxy(value == "true"))),
|
||||
_ => Err("Invalid widget request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(backend: impl Backend) -> anyhow::Result<()> {
|
||||
let native = load_native()?;
|
||||
let raw = unsafe { (native.path)() };
|
||||
anyhow::ensure!(!raw.is_null(), "App Group container unavailable");
|
||||
let path = unsafe { CStr::from_ptr(raw).to_string_lossy().into_owned() };
|
||||
unsafe { libc::free(raw.cast()) };
|
||||
// A second process must not unlink the live app's listener.
|
||||
if tokio::net::UnixStream::connect(&path).await.is_ok() {
|
||||
anyhow::bail!("Widget listener already running");
|
||||
}
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => (),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||
let _ = NATIVE.set(native);
|
||||
reload();
|
||||
let mut last_error = None;
|
||||
let mut pending = None;
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let Some(native) = NATIVE.get() else { break };
|
||||
if !unsafe { (native.authenticate)(stream.as_raw_fd()) } {
|
||||
continue;
|
||||
}
|
||||
// Serialize widget mutations; never cancel a backend transaction on client timeout.
|
||||
let mut stream = BufReader::new(stream);
|
||||
let mut line = Vec::new();
|
||||
let read = timeout(
|
||||
Duration::from_secs(2),
|
||||
(&mut stream).take(1024).read_until(b'\n', &mut line),
|
||||
)
|
||||
.await;
|
||||
if !matches!(read, Ok(Ok(_))) || line.last() != Some(&b'\n') {
|
||||
continue;
|
||||
}
|
||||
let request = match serde_json::from_slice::<Request>(&line) {
|
||||
Ok(request) => request,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let action = match request.action() {
|
||||
Ok(action) => action,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let changed = action.is_some();
|
||||
// Coalesce hooks across the whole transaction, including errors and disconnected clients.
|
||||
MUTATING.store(changed, Ordering::Release);
|
||||
let refresh = scopeguard::guard(changed, |changed| {
|
||||
MUTATING.store(false, Ordering::Release);
|
||||
if changed {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
// Mutations reply without re-reading state so the widget repaints one round trip
|
||||
// sooner; the status read after the reload confirms the pending action.
|
||||
let mut reply = match action {
|
||||
Some(action) => {
|
||||
match backend.apply(action).await {
|
||||
Ok(()) => {
|
||||
last_error = None;
|
||||
pending = Some(action);
|
||||
}
|
||||
Err(error) => {
|
||||
pending = None;
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
Status::default()
|
||||
}
|
||||
None => {
|
||||
let state = backend.snapshot().await;
|
||||
if let Some(action) = pending.take()
|
||||
&& !state.confirms(action)
|
||||
{
|
||||
last_error = Some("widget.failed".into());
|
||||
}
|
||||
state
|
||||
}
|
||||
};
|
||||
if action.is_some() {
|
||||
reply.language = backend.language().await;
|
||||
}
|
||||
reply.error = last_error.take();
|
||||
let serialized = serde_json::to_vec(&reply);
|
||||
last_error = reply.error.take();
|
||||
let mut bytes = serialized?;
|
||||
bytes.push(b'\n');
|
||||
let _ = timeout(Duration::from_secs(2), stream.get_mut().write_all(&bytes)).await;
|
||||
drop(refresh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start(backend: impl Backend) {
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
if STARTED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Darwin 23 is macOS 14; older hosts must never load the WidgetKit bridge.
|
||||
let mut version = [0u8; 64];
|
||||
let mut length = version.len();
|
||||
let result = unsafe {
|
||||
libc::sysctlbyname(
|
||||
c"kern.osrelease".as_ptr(),
|
||||
version.as_mut_ptr().cast(),
|
||||
&mut length,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
let major = std::str::from_utf8(&version)
|
||||
.ok()
|
||||
.and_then(|v| v.split('.').next()?.parse::<u32>().ok());
|
||||
if result != 0 || major.is_none_or(|major| major < 23) {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = serve(backend).await {
|
||||
log::warn!("Widget unavailable: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
rust-version = "1.91"
|
||||
|
||||
[dependencies]
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-clipboard-manager = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
@@ -14,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.189"
|
||||
libc = "0.2.190"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
deelevate = { workspace = true }
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Runtime, State, command};
|
||||
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt as _;
|
||||
|
||||
use crate::Platform;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, specta::Type)]
|
||||
pub struct SystemInfo {
|
||||
pub system_name: String,
|
||||
pub system_version: String,
|
||||
pub system_kernel_version: String,
|
||||
pub system_arch: String,
|
||||
pub app_version: String,
|
||||
pub app_core_mode: String,
|
||||
pub app_is_admin: bool,
|
||||
}
|
||||
|
||||
impl From<Platform> for SystemInfo {
|
||||
fn from(platform: Platform) -> Self {
|
||||
impl From<&Platform> for SystemInfo {
|
||||
fn from(platform: &Platform) -> Self {
|
||||
Self {
|
||||
system_name: platform.sysinfo.system_name,
|
||||
system_version: platform.sysinfo.system_version,
|
||||
system_kernel_version: platform.sysinfo.system_kernel_version,
|
||||
system_arch: platform.sysinfo.system_arch,
|
||||
app_version: platform.appinfo.app_version,
|
||||
app_core_mode: platform.appinfo.app_core_mode,
|
||||
app_is_admin: platform.appinfo.app_is_admin,
|
||||
system_name: platform.sysinfo.system_name.clone(),
|
||||
system_version: platform.sysinfo.system_version.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
|
||||
#[specta::specta]
|
||||
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
|
||||
let platform = state.inner().read();
|
||||
Ok(SystemInfo::from(platform.clone()))
|
||||
Ok(SystemInfo::from(&*platform))
|
||||
}
|
||||
|
||||
/// 获取应用的运行时间(毫秒)
|
||||
#[command]
|
||||
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> {
|
||||
#[specta::specta]
|
||||
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
|
||||
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub fn export_diagnostic_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
state: State<'_, RwLock<Platform>>,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<(), String> {
|
||||
let info = state.inner().read().to_string();
|
||||
let clipboard = app_handle.clipboard();
|
||||
clipboard.write_text(info)
|
||||
clipboard.write_text(info).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,269 @@
|
||||
## v2.5.8
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
|
||||
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
|
||||
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
|
||||
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
|
||||
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
|
||||
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
|
||||
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
|
||||
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
|
||||
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
|
||||
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
|
||||
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
|
||||
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
|
||||
|
||||
**🖥️/🍎 Windows/macOS**
|
||||
|
||||
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增系统托盘代理组中的「测试延迟」菜单项
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化端口设置窗口的实现,精简重复代码
|
||||
- 优化侧边栏流量图表的 CPU 占用
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
|
||||
|
||||
</details>
|
||||
|
||||
## v2.5.7
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
||||
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
||||
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
||||
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.6
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
||||
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
||||
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
|
||||
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
|
||||
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
|
||||
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
|
||||
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
|
||||
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
|
||||
|
||||
</details>
|
||||
|
||||
## v2.5.5
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
- 优化预构建资源,补充 ASN 数据库文件
|
||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
||||
|
||||
</details>
|
||||
|
||||
## v2.5.4
|
||||
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||
- 修复重启内核后未恢复上次所选节点的问题
|
||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||
- 修复切换节点时应用可能卡住的问题
|
||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
||||
- 修复 Windows 系统代理设置偶发失败的问题
|
||||
- 修复 Windows 关机被应用阻止的问题
|
||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS 内核更新失败后无法启动的问题
|
||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
||||
|
||||
**🍎/🐧 macOS/Linux**
|
||||
|
||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||
- 新增手动取消节点固定功能
|
||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||
- 新增前置规则与后置规则互相移动功能
|
||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||
- 新增启动失败提示,显示原因并保存启动日志
|
||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 新增 macOS Liquid Glass 风格图标
|
||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
- 优化首页节点操作,显示节点固定状态
|
||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||
- 优化更新说明排版,支持表格和提示框
|
||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||
- 优化后台连接和托盘刷新,降低资源占用
|
||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||
- 优化日志记录,减少重复信息
|
||||
- 移除便携模式,统一使用系统应用数据目录
|
||||
- 优化繁体中文、俄语翻译及语言名称显示
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 优化 Windows 自动更新,跳过安装语言选择
|
||||
|
||||
**🐧 Linux**
|
||||
|
||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## v2.5.2
|
||||
|
||||
### 🐞 修复问题
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
|
||||
|
||||
<p align="center">
|
||||
Languages:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Preview
|
||||
@@ -51,11 +52,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
|
||||
## Promotion
|
||||
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
|
||||
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
#### **Core Advantages:**
|
||||
|
||||
@@ -67,7 +68,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
||||
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
||||
- 🎬 Unlocks **streaming platforms and mainstream AI services**
|
||||
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
|
||||
|
||||
<p align="center">
|
||||
Idiomas:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Vista previa
|
||||
@@ -51,17 +52,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
||||
|
||||
## Promociones
|
||||
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
|
||||
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
|
||||
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
|
||||
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
|
||||
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
|
||||
- Desbloquea servicios de streaming y acceso a ChatGPT
|
||||
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<p align="center">
|
||||
زبانها:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## پیشنمایش
|
||||
@@ -50,17 +51,17 @@
|
||||
|
||||
## تبلیغات
|
||||
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
||||
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
||||
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
||||
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
||||
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<p align="center">
|
||||
言語:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## プレビュー
|
||||
@@ -51,17 +52,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
||||
|
||||
## プロモーション
|
||||
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
||||
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
||||
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
||||
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
||||
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
||||
- ストリーミングおよび ChatGPT の利用にも対応
|
||||
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
||||
|
||||
|
||||
+7
-6
@@ -11,13 +11,14 @@
|
||||
|
||||
<p align="center">
|
||||
언어:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## 미리보기
|
||||
@@ -51,17 +52,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
||||
|
||||
## 프로모션
|
||||
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
||||
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
||||
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
||||
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
||||
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
||||
- 스트리밍 및 ChatGPT 접근 지원
|
||||
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<h1 align="center">
|
||||
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
|
||||
<br>
|
||||
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<h3 align="center">
|
||||
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
|
||||
</h3>
|
||||
|
||||
<p align="center">
|
||||
Idiomas:
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
## Pré-visualização
|
||||
|
||||
| Escuro | Claro |
|
||||
| -------------------------------------- | -------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
## Instalação
|
||||
|
||||
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
|
||||
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
|
||||
|
||||
#### Como escolher o canal de lançamento
|
||||
|
||||
| Canal | Descrição | Link |
|
||||
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
|
||||
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
|
||||
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
|
||||
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
|
||||
|
||||
#### Guias de instalação e perguntas frequentes
|
||||
|
||||
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
|
||||
|
||||
### Canal do Telegram
|
||||
|
||||
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
|
||||
|
||||
---
|
||||
|
||||
## Promoções
|
||||
|
||||
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
|
||||
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
|
||||
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
|
||||
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
|
||||
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
|
||||
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
|
||||
- Desbloqueia serviços de streaming e acesso ao ChatGPT
|
||||
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
|
||||
|
||||
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
|
||||
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
|
||||
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
|
||||
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
|
||||
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
|
||||
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
|
||||
|
||||
---
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
- Baseado em Rust de alto desempenho e no framework Tauri 2
|
||||
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
|
||||
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
|
||||
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
|
||||
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
|
||||
- Editores visuais para nós e regras
|
||||
- Backup e sincronização via WebDAV
|
||||
|
||||
### Perguntas frequentes
|
||||
|
||||
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
|
||||
|
||||
### Doações
|
||||
|
||||
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
|
||||
|
||||
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
|
||||
|
||||
```shell
|
||||
pnpm i
|
||||
pnpm run prebuild
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Contribuições
|
||||
|
||||
Issues e pull requests são bem-vindos.
|
||||
|
||||
## Agradecimentos
|
||||
|
||||
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
|
||||
|
||||
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
|
||||
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
|
||||
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
|
||||
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
|
||||
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
|
||||
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
|
||||
|
||||
## Licença
|
||||
|
||||
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
|
||||
+7
-6
@@ -11,13 +11,14 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
||||
|
||||
<p align="center">
|
||||
Языки:
|
||||
<a href="../README.md">简体中文</a> ·
|
||||
<a href="./README_en.md">English</a> ·
|
||||
<a href="./README_es.md">Español</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="./README_fa.md">فارسی</a> ·
|
||||
<a href="./README_ja.md">日本語</a> ·
|
||||
<a href="./README_ko.md">한국어</a> ·
|
||||
<a href="./README_fa.md">فارسی</a>
|
||||
<a href="./README_pt.md">Português</a> ·
|
||||
<a href="./README_ru.md">Русский</a> ·
|
||||
<a href="../README.md">简体中文</a>
|
||||
</p>
|
||||
## Предпросмотр
|
||||
|
||||
@@ -47,17 +48,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
||||
|
||||
## Продвижение
|
||||
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
||||
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
||||
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
||||
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
||||
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
||||
- Разблокировка потоковые сервисы и ChatGPT
|
||||
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
||||
|
||||
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
||||
|
||||
|
||||
+116
-2
@@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
const ipcBoundaryRules = ({
|
||||
allowCommands = false,
|
||||
allowInvoke = false,
|
||||
allowEvents = false,
|
||||
} = {}) => {
|
||||
const paths = [
|
||||
...(!allowInvoke
|
||||
? [
|
||||
{
|
||||
name: '@tauri-apps/api/core',
|
||||
importNames: ['invoke'],
|
||||
allowTypeImports: true,
|
||||
message: 'Invoke backend commands through services/cmds.',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!allowEvents
|
||||
? [
|
||||
{
|
||||
name: '@tauri-apps/api/event',
|
||||
importNames: ['listen', 'emit', 'emitTo', 'once'],
|
||||
allowTypeImports: true,
|
||||
message:
|
||||
'The event bus owns Tauri event subscriptions and emissions.',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const patterns = allowCommands
|
||||
? []
|
||||
: [
|
||||
{
|
||||
regex: '(^|/)bindings(\\.ts)?$',
|
||||
importNames: ['commands'],
|
||||
allowTypeImports: true,
|
||||
message:
|
||||
'Only services/cmds may import generated command implementations.',
|
||||
},
|
||||
]
|
||||
const modules = [
|
||||
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
|
||||
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
|
||||
]
|
||||
const syntax = modules.flatMap((name) => [
|
||||
{
|
||||
selector: `ImportExpression[source.value='${name}']`,
|
||||
message:
|
||||
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
|
||||
},
|
||||
{
|
||||
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
|
||||
message:
|
||||
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
|
||||
},
|
||||
{
|
||||
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
|
||||
message: 'Use the owned IPC service boundary for Tauri APIs.',
|
||||
},
|
||||
])
|
||||
if (!allowCommands) {
|
||||
syntax.push(
|
||||
{
|
||||
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
|
||||
message:
|
||||
'Only services/cmds may load generated command implementations.',
|
||||
},
|
||||
)
|
||||
}
|
||||
return {
|
||||
'no-restricted-imports': ['error', { paths, patterns }],
|
||||
'no-restricted-syntax': ['error', ...syntax],
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig([
|
||||
pluginESx.configs['flat/restrict-to-es2022'],
|
||||
{
|
||||
@@ -43,7 +129,6 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'crates/clash-verge-widget/build/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
@@ -144,7 +229,7 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
|
||||
files: ['scripts/*.mjs'],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -163,4 +248,33 @@ export default defineConfig([
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
|
||||
rules: ipcBoundaryRules(),
|
||||
},
|
||||
{
|
||||
files: ['src/services/cmds.ts'],
|
||||
rules: ipcBoundaryRules({ allowCommands: true }),
|
||||
},
|
||||
{
|
||||
files: ['src/services/bindings.ts'],
|
||||
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
|
||||
},
|
||||
{
|
||||
files: ['src/services/bus/**/*.{ts,tsx}'],
|
||||
rules: ipcBoundaryRules({ allowEvents: true }),
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/components/proxy/proxy-chain.tsx',
|
||||
'src/components/proxy/proxy-groups-chain.tsx',
|
||||
'src/pages/proxies.tsx',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': [
|
||||
'error',
|
||||
{ ignoreVoid: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/polyfills/*.js!"],
|
||||
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
|
||||
"include": [
|
||||
"files",
|
||||
@@ -17,7 +17,8 @@
|
||||
"ignoreBinaries": ["rustc", "cc"],
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"]
|
||||
"src/types/generated/**": ["exports", "types"],
|
||||
"src/services/bindings.ts": ["types"]
|
||||
},
|
||||
"cycles": {
|
||||
"dynamicImports": true
|
||||
|
||||
+62
-60
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.9",
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
"prepare": "husky || true",
|
||||
@@ -10,8 +10,8 @@
|
||||
"dev:diff": "node scripts/dev.mjs dev",
|
||||
"dev:trace": "node scripts/dev.mjs trace",
|
||||
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
|
||||
"build": "node scripts/build.mjs",
|
||||
"build:fast": "node scripts/build.mjs -- --profile fast-release",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
||||
"tauri": "tauri",
|
||||
"web:dev": "vite",
|
||||
"web:build": "tsc --noEmit && vite build",
|
||||
@@ -30,6 +30,8 @@
|
||||
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
||||
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
||||
"gen:contract": "node scripts/gen-contract.mjs",
|
||||
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -49,90 +51,90 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-http": "~2.5.9",
|
||||
"@tauri-apps/plugin-opener": "2.5.4",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"ahooks": "^3.9.7",
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tauri-apps/api": "2.12.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "2.8.0",
|
||||
"@tauri-apps/plugin-fs": "2.6.0",
|
||||
"@tauri-apps/plugin-http": "2.8.0",
|
||||
"@tauri-apps/plugin-opener": "2.7.0",
|
||||
"@tauri-apps/plugin-updater": "2.13.1",
|
||||
"ahooks": "^3.10.0",
|
||||
"cidr-block": "^2.3.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"dayjs": "1.11.21",
|
||||
"foxact": "^0.3.4",
|
||||
"foxts": "^5.4.2",
|
||||
"i18next": "^26.2.0",
|
||||
"js-yaml": "^5.0.0",
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.12",
|
||||
"foxts": "^5.9.1",
|
||||
"i18next": "^26.4.2",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.25",
|
||||
"monaco-editor": "^0.56.0",
|
||||
"monaco-yaml": "^5.5.0",
|
||||
"nanoid": "^6.0.0",
|
||||
"meta-json-schema": "^1.19.31",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"nanoid": "^6.0.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"react-error-boundary": "6.1.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "17.0.11",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.0.15",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.0.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"swr": "^2.4.2",
|
||||
"swr": "^2.5.1",
|
||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||
"types-pac": "^1.0.3",
|
||||
"validator": "^13.15.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/github": "^9.1.1",
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@tauri-apps/cli": "2.12.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wdio/tauri-service": "1.3.0",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.16.1",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.4.0",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"cli-color": "^2.0.4",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-es-x": "^10.0.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-es-x": "^10.0.1",
|
||||
"eslint-plugin-import-x": "^4.17.1",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-sukka": "^10.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"eslint-plugin-sukka": "^10.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"globals": "^17.6.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"globals": "^17.12.0",
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.32.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
"tar": "^7.5.15",
|
||||
"terser": "^5.48.0",
|
||||
"knip": "6.39.0",
|
||||
"lint-staged": "^17.5.1",
|
||||
"sass": "^1.104.1",
|
||||
"tar": "^7.5.22",
|
||||
"terser": "^5.51.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vite": "^8.0.14",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.0",
|
||||
"webdriverio": "9.30.0",
|
||||
"ws": "8.21.0"
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.0.0",
|
||||
"ws": "8.22.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
@@ -144,5 +146,5 @@
|
||||
]
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@12.4.1"
|
||||
"packageManager": "pnpm@12.11.2"
|
||||
}
|
||||
|
||||
Generated
+1547
-1791
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -56,6 +56,12 @@
|
||||
"matchFileNames": [".github/workflows/*.lock.yml"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
|
||||
"matchFileNames": [".github/workflows/**"],
|
||||
"matchPackageNames": ["/gh-aw/"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Skip TypeScript 7.0 series",
|
||||
"matchManagers": ["npm"],
|
||||
@@ -78,7 +84,7 @@
|
||||
},
|
||||
{
|
||||
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
||||
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
|
||||
"matchPackageNames": ["pnpm"],
|
||||
"groupName": "pnpm"
|
||||
},
|
||||
{
|
||||
@@ -131,6 +137,16 @@
|
||||
],
|
||||
"depNameTemplate": "rust-lang/rust",
|
||||
"datasourceTemplate": "github-releases"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Update pnpm in devcontainer.json",
|
||||
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
|
||||
"matchStrings": [
|
||||
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "pnpm",
|
||||
"datasourceTemplate": "npm"
|
||||
}
|
||||
],
|
||||
"postUpdateOptions": ["pnpmDedupe"],
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.98.1"
|
||||
channel = "1.99.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const root = join(import.meta.dirname, '..')
|
||||
|
||||
const forward = () => {
|
||||
const tauri = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'tauri', 'build', ...process.argv.slice(2)],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=4096',
|
||||
},
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
)
|
||||
process.exit(tauri.status ?? 1)
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') forward()
|
||||
|
||||
if (process.env.CLASH_VERGE_WIDGET === '0') {
|
||||
console.warn(
|
||||
'CLASH_VERGE_WIDGET=0: building without the desktop widget extension.',
|
||||
)
|
||||
process.env.APPLE_SIGNING_IDENTITY = '-'
|
||||
forward()
|
||||
}
|
||||
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
if (result.error) return ''
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
let identity = process.env.APPLE_SIGNING_IDENTITY
|
||||
if (!identity) {
|
||||
const mac = JSON.parse(
|
||||
readFileSync(join(root, 'src-tauri/tauri.macos.conf.json'), 'utf8'),
|
||||
)
|
||||
identity = mac.bundle?.macOS?.signingIdentity || undefined
|
||||
}
|
||||
if (!identity) {
|
||||
const identities = [
|
||||
...run('security', ['find-identity', '-v', '-p', 'codesigning']).matchAll(
|
||||
/\b([A-F0-9]{40}) "([^"]+)"/g,
|
||||
),
|
||||
]
|
||||
if (identities.length === 1) identity = identities[0][2]
|
||||
}
|
||||
if (!identity) {
|
||||
console.error(
|
||||
'macOS packaging needs the widget extension signed with the host: set APPLE_SIGNING_IDENTITY to a valid certificate, or export CLASH_VERGE_WIDGET=0 to build without the widget.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.env.APPLE_SIGNING_IDENTITY = identity
|
||||
forward()
|
||||
@@ -0,0 +1,34 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { ESLint } from 'eslint'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const gates = process.argv.slice(2)
|
||||
if (
|
||||
!gates.length ||
|
||||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
|
||||
) {
|
||||
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
|
||||
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
|
||||
const results = await eslint.lintFiles(['src'])
|
||||
const boundaryRules = new Set([
|
||||
'no-restricted-imports',
|
||||
'no-restricted-syntax',
|
||||
'@typescript-eslint/no-floating-promises',
|
||||
])
|
||||
const violations = results.flatMap((result) =>
|
||||
result.messages
|
||||
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
|
||||
.map((message) => ({ file: result.filePath, ...message })),
|
||||
)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
|
||||
)
|
||||
}
|
||||
if (violations.length) process.exitCode = 1
|
||||
else console.log('[arch] no violations')
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const { contract } = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
|
||||
)
|
||||
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length !== 2 || args[0] !== '--push') {
|
||||
console.error('usage: check-contract-changes.mjs --push <remote>')
|
||||
process.exit(2)
|
||||
}
|
||||
const hasChanges = (...range) =>
|
||||
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
|
||||
const assertCleanInputs = (...range) => {
|
||||
if (
|
||||
hasChanges(...range) ||
|
||||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
|
||||
) {
|
||||
throw new Error(
|
||||
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
|
||||
)
|
||||
}
|
||||
}
|
||||
let relevant = false
|
||||
const head = git('rev-parse', 'HEAD')
|
||||
for (const line of fs
|
||||
.readFileSync(0, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)) {
|
||||
const fields = line.trim().split(/\s+/)
|
||||
if (
|
||||
fields.length !== 4 ||
|
||||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
|
||||
) {
|
||||
throw new Error('Invalid pre-push ref update')
|
||||
}
|
||||
const [, local, , remote] = fields
|
||||
if (/^0+$/.test(local) || local === remote) continue
|
||||
// A new remote branch has no upstream; use its fork point with the remote default branch.
|
||||
const base = /^0+$/.test(remote)
|
||||
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
|
||||
: remote
|
||||
if (!hasChanges(base, local)) continue
|
||||
const commit = git('rev-parse', `${local}^{commit}`)
|
||||
if (commit !== head)
|
||||
throw new Error(
|
||||
'Check out the pushed commit before validating its changed IPC contract.',
|
||||
)
|
||||
assertCleanInputs('HEAD')
|
||||
relevant = true
|
||||
}
|
||||
if (relevant) {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
|
||||
{
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
console.log('[contract] inputs unchanged; skipping Rust generation')
|
||||
}
|
||||
@@ -49,7 +49,7 @@ const FRONTEND_EXTENSIONS = new Set([
|
||||
'.vue',
|
||||
'.json',
|
||||
])
|
||||
const BACKEND_EXTENSIONS = new Set(['.rs', '.swift'])
|
||||
const BACKEND_EXTENSIONS = new Set(['.rs'])
|
||||
|
||||
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"contract": [
|
||||
"src-tauri/**/*.rs",
|
||||
"crates/**/*.rs",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"src-tauri/Cargo.toml",
|
||||
"crates/**/Cargo.toml",
|
||||
"rust-toolchain.toml",
|
||||
"src-tauri/tauri.conf*.json",
|
||||
"src/services/bindings.ts",
|
||||
"scripts/gen-contract.mjs",
|
||||
"scripts/check-contract-changes.mjs",
|
||||
"biome.json",
|
||||
"biome.jsonc",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"scripts/contract-inputs.json",
|
||||
".github/workflows/frontend-check.yml",
|
||||
".husky/pre-push"
|
||||
]
|
||||
}
|
||||
+86
-142
@@ -1,36 +1,23 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { access } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const developmentServiceDirectoryEnvironment =
|
||||
'CLASH_VERGE_DEV_SERVICE_DIR'
|
||||
const developmentServiceInstallerEnvironment =
|
||||
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const repositoryRoot = resolve(scriptDirectory, '..')
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const serviceRepository = resolve(
|
||||
repositoryRoot,
|
||||
'..',
|
||||
'clash-verge-service-ipc',
|
||||
)
|
||||
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
||||
const developmentReceipt = join(
|
||||
repositoryRoot,
|
||||
'target',
|
||||
'development-service.json',
|
||||
)
|
||||
|
||||
function executable(name, platform) {
|
||||
return join(
|
||||
serviceRepository,
|
||||
'target',
|
||||
'debug',
|
||||
platform === 'win32' ? `${name}.exe` : name,
|
||||
)
|
||||
}
|
||||
export const developmentServiceWatchPaths = [
|
||||
'src',
|
||||
'resources',
|
||||
'Cargo.toml',
|
||||
'Cargo.lock',
|
||||
].map((name) => join(serviceRepository, name))
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
@@ -39,10 +26,15 @@ function run(command, args, options = {}) {
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
windowsHide: true,
|
||||
})
|
||||
let stdout = ''
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
resolvePromise(stdout)
|
||||
return
|
||||
}
|
||||
const error = new Error(
|
||||
@@ -54,134 +46,86 @@ function run(command, args, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
async function hashFiles(paths) {
|
||||
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,
|
||||
} = {}) {
|
||||
export async function prepareDevelopmentService() {
|
||||
await access(serviceManifest)
|
||||
await run('cargo', [
|
||||
'build',
|
||||
'--manifest-path',
|
||||
serviceManifest,
|
||||
'--features',
|
||||
'standalone,client,development-channel',
|
||||
'--bins',
|
||||
])
|
||||
const output = await run(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
serviceManifest,
|
||||
'--target-dir',
|
||||
process.env.CARGO_TARGET_DIR ||
|
||||
join(serviceRepository, 'target', 'development'),
|
||||
'--features',
|
||||
'standalone,client,development-channel',
|
||||
'--bins',
|
||||
'--message-format=json-render-diagnostics',
|
||||
],
|
||||
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
||||
)
|
||||
|
||||
const service = executable('clash-verge-service', platform)
|
||||
await access(service)
|
||||
return dirname(service)
|
||||
const artifacts = new Map()
|
||||
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
||||
const artifact = JSON.parse(line)
|
||||
if (
|
||||
artifact.reason === 'compiler-artifact' &&
|
||||
resolve(artifact.manifest_path) === serviceManifest &&
|
||||
artifact.target.kind.includes('bin') &&
|
||||
artifact.executable
|
||||
) {
|
||||
artifacts.set(artifact.target.name, resolve(artifact.executable))
|
||||
}
|
||||
}
|
||||
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({
|
||||
platform = process.platform,
|
||||
} = {}) {
|
||||
const serviceDirectory = await prepareDevelopmentService({ platform })
|
||||
const service = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
|
||||
)
|
||||
const serviceDirectory = await prepareDevelopmentService()
|
||||
const extension = platform === 'win32' ? '.exe' : ''
|
||||
const installer = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32'
|
||||
? 'clash-verge-service-install.exe'
|
||||
: 'clash-verge-service-install',
|
||||
`clash-verge-service-install${extension}`,
|
||||
)
|
||||
const driver = join(
|
||||
serviceDirectory,
|
||||
platform === 'win32'
|
||||
? 'service-integration-driver.exe'
|
||||
: 'service-integration-driver',
|
||||
)
|
||||
const fingerprint = await hashFiles([service, installer])
|
||||
const installedFingerprint = await readInstalledFingerprint()
|
||||
|
||||
if (installedFingerprint === fingerprint) {
|
||||
try {
|
||||
await run(driver, ['probe'], { stdio: 'ignore' })
|
||||
return serviceDirectory
|
||||
} catch {
|
||||
// The expected binary is installed but unavailable; the repair tool owns recovery.
|
||||
}
|
||||
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
|
||||
/^host: (.+)$/m,
|
||||
)?.[1]
|
||||
if (!host) throw new Error('rustc did not report its host target')
|
||||
const args = ['--prepare-install', '--ensure']
|
||||
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
|
||||
args.push(
|
||||
'--core',
|
||||
`${name}${extension}`,
|
||||
join(
|
||||
repositoryRoot,
|
||||
'src-tauri',
|
||||
'sidecar',
|
||||
`${name}-${host}${extension}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await elevateInstaller(installer, platform)
|
||||
} catch (error) {
|
||||
if (error.exitCode !== 75) throw error
|
||||
}
|
||||
await run(driver, ['ready'])
|
||||
await writeInstalledFingerprint(fingerprint)
|
||||
await run(installer, args)
|
||||
return serviceDirectory
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './dev-control.mjs'
|
||||
import {
|
||||
developmentServiceDirectoryEnvironment,
|
||||
developmentServiceWatchPaths,
|
||||
ensureDevelopmentService,
|
||||
prepareDevelopmentService,
|
||||
} from './dev-service.mjs'
|
||||
@@ -31,6 +32,9 @@ export function buildTauriInvocation(
|
||||
env.RUSTFLAGS = '--cfg tokio_unstable'
|
||||
args.push('tokio-trace')
|
||||
}
|
||||
for (const path of developmentServiceWatchPaths) {
|
||||
args.push('--additional-watch-folders', path)
|
||||
}
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
|
||||
console.error('usage: gen-contract.mjs [--check]')
|
||||
process.exit(2)
|
||||
}
|
||||
const check = args[0] === '--check'
|
||||
const temporary = check
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
|
||||
: null
|
||||
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
|
||||
try {
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'run',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--example',
|
||||
'frontend-contract',
|
||||
'--locked',
|
||||
'-q',
|
||||
...(check ? ['--', output] : []),
|
||||
],
|
||||
{ cwd: ROOT, stdio: 'inherit' },
|
||||
)
|
||||
const formatted = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
|
||||
'format',
|
||||
'--stdin-file-path',
|
||||
BINDINGS,
|
||||
],
|
||||
{ cwd: ROOT, input: fs.readFileSync(output) },
|
||||
)
|
||||
if (check) {
|
||||
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
|
||||
console.error(
|
||||
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(BINDINGS, formatted)
|
||||
}
|
||||
} finally {
|
||||
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
+53
-11
@@ -1,9 +1,9 @@
|
||||
import { execFileSync, execSync } from 'child_process'
|
||||
import { createHash } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import zlib from 'zlib'
|
||||
import { execFileSync, execSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
import AdmZip from 'adm-zip'
|
||||
import { glob } from 'glob'
|
||||
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
|
||||
const zip = new AdmZip(tempZip)
|
||||
zip
|
||||
.getEntries()
|
||||
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||
zip.extractAllTo(tempDir, true)
|
||||
if (fs.existsSync(tempDll)) {
|
||||
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
||||
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
|
||||
|
||||
function serviceFileInfo(name) {
|
||||
const ext = platform === 'win32' ? '.exe' : ''
|
||||
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
|
||||
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
|
||||
return {
|
||||
sourceFile: `${name}${ext}`,
|
||||
targetFile: `${name}${suffix}${ext}`,
|
||||
@@ -586,6 +586,41 @@ async function resolveServiceBundle() {
|
||||
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
||||
'utf8',
|
||||
)
|
||||
const serviceDependency = cargoManifest
|
||||
.split(/\r?\n/)
|
||||
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
|
||||
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
|
||||
if (sourcePath) {
|
||||
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
|
||||
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'--manifest-path',
|
||||
manifest,
|
||||
'--target-dir',
|
||||
targetDirectory,
|
||||
'--target',
|
||||
SIDECAR_HOST,
|
||||
'--release',
|
||||
'--features',
|
||||
'standalone,client',
|
||||
'--bins',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
await fsp.mkdir(SERVICE_DIR, { recursive: true })
|
||||
for (const { sourceFile, targetPath } of files) {
|
||||
await fsp.copyFile(
|
||||
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
|
||||
targetPath,
|
||||
)
|
||||
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
|
||||
await updateHashCache(targetPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
const { archiveFile, downloadURL } = resolveServiceRelease(
|
||||
cargoManifest,
|
||||
SIDECAR_HOST,
|
||||
@@ -604,8 +639,9 @@ async function resolveServiceBundle() {
|
||||
const zip = new AdmZip(tempArchive)
|
||||
zip
|
||||
.getEntries()
|
||||
.forEach((entry) =>
|
||||
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||
.forEach(
|
||||
(entry) =>
|
||||
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||
)
|
||||
zip.extractAllTo(tempDir, true)
|
||||
} else {
|
||||
@@ -652,7 +688,7 @@ async function resolveCoreHashes() {
|
||||
.digest('hex')
|
||||
lines.push(`!define ${define} "${digest}"`)
|
||||
}
|
||||
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
|
||||
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
|
||||
log_success(`Generated ${CORE_HASHES_NSH}`)
|
||||
}
|
||||
|
||||
@@ -661,6 +697,11 @@ const resolveMmdb = () =>
|
||||
file: 'Country.mmdb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
||||
})
|
||||
const resolveASNMmdb = () =>
|
||||
resolveResource({
|
||||
file: 'ASN.mmdb',
|
||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
|
||||
})
|
||||
const resolveGeosite = () =>
|
||||
resolveResource({
|
||||
file: 'geosite.dat',
|
||||
@@ -706,6 +747,7 @@ const tasks = [
|
||||
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
||||
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
|
||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
||||
{
|
||||
|
||||
+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:
|
||||
* pnpm release-version <version>
|
||||
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
|
||||
'[INFO]: Current package.json version is: ',
|
||||
packageJson.version,
|
||||
)
|
||||
packageJson.version = newVersion.startsWith('v')
|
||||
const versionWithoutV = newVersion.startsWith('v')
|
||||
? newVersion.slice(1)
|
||||
: newVersion
|
||||
await fs.writeFile(
|
||||
packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
console.log(
|
||||
`[INFO]: package.json version updated to: ${packageJson.version}`,
|
||||
const updatedData = data.replace(
|
||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||
`$1"${versionWithoutV}"`,
|
||||
)
|
||||
|
||||
if (updatedData === data) {
|
||||
throw new Error('version field was not found in package.json')
|
||||
}
|
||||
|
||||
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
|
||||
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
|
||||
} catch (error) {
|
||||
console.error('Error updating package.json version:', error)
|
||||
throw error
|
||||
@@ -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) {
|
||||
const _dirname = process.cwd()
|
||||
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
||||
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
|
||||
tauriConfig.version,
|
||||
)
|
||||
|
||||
tauriConfig.version = versionWithoutV
|
||||
|
||||
await fs.writeFile(
|
||||
tauriConfigPath,
|
||||
JSON.stringify(tauriConfig, null, 2),
|
||||
'utf8',
|
||||
const updatedData = data.replace(
|
||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
||||
`$1"${versionWithoutV}"`,
|
||||
)
|
||||
|
||||
if (updatedData === data) {
|
||||
throw new Error('version field was not found in tauri.conf.json')
|
||||
}
|
||||
|
||||
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
|
||||
console.log(
|
||||
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
||||
)
|
||||
@@ -239,6 +273,7 @@ async function main(versionArg) {
|
||||
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
||||
await updatePackageVersion(newVersion)
|
||||
await updateCargoVersion(newVersion)
|
||||
await updateCargoLockVersion(newVersion)
|
||||
await updateTauriConfigVersion(newVersion)
|
||||
console.log('[SUCCESS]: All version updates completed successfully!')
|
||||
} catch (error) {
|
||||
|
||||
+41
-19
@@ -30,29 +30,51 @@ function is_valid_ip() {
|
||||
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
|
||||
}
|
||||
|
||||
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||
function sc_value() {
|
||||
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||
}
|
||||
|
||||
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||
function dns_of() {
|
||||
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
|
||||
dns=$(networksetup -getdnsservers "$1") || return 1
|
||||
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
|
||||
echo "empty"
|
||||
return
|
||||
fi
|
||||
[ -n "$dns" ] || return 1
|
||||
while read -r line; do
|
||||
[[ $line =~ $ip ]] || return 1
|
||||
done <<<"$dns"
|
||||
echo "$dns"
|
||||
}
|
||||
|
||||
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
|
||||
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
||||
|
||||
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
||||
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
||||
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
||||
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
||||
')
|
||||
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
|
||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
|
||||
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
|
||||
|
||||
original_dns=$(networksetup -getdnsservers "$hardware_port")
|
||||
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
|
||||
|
||||
is_valid_dns=false
|
||||
for ip in $original_dns; do
|
||||
ip=$(echo "$ip" | tr -d '[:space:]')
|
||||
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
|
||||
is_valid_dns=true
|
||||
break
|
||||
backup=".original_dns/$service_id"
|
||||
mkdir -p .original_dns || exit 1
|
||||
# Older releases kept a single backup for whichever service was primary.
|
||||
if [ -f .original_dns.txt ]; then
|
||||
if [ -f "$backup" ]; then
|
||||
rm -f .original_dns.txt || exit 1
|
||||
else
|
||||
mv .original_dns.txt "$backup" || exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$is_valid_dns" = false ]; then
|
||||
echo "empty" >.original_dns.txt
|
||||
else
|
||||
echo "$original_dns" >.original_dns.txt
|
||||
fi
|
||||
networksetup -setdnsservers "$hardware_port" "$1"
|
||||
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
|
||||
if [ "$original_dns" != "$1" ]; then
|
||||
echo "$original_dns" >"$backup" || exit 1
|
||||
networksetup -setdnsservers "$hardware_port" "$1"
|
||||
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
|
||||
elif [ ! -f "$backup" ]; then
|
||||
echo "empty" >"$backup" || exit 1
|
||||
fi
|
||||
|
||||
+57
-8
@@ -1,13 +1,62 @@
|
||||
#!/bin/bash
|
||||
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
||||
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
|
||||
|
||||
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
||||
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
||||
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
||||
')
|
||||
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
||||
function sc_value() {
|
||||
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
||||
}
|
||||
|
||||
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
||||
function dns_of() {
|
||||
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
|
||||
dns=$(networksetup -getdnsservers "$1") || return 1
|
||||
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
|
||||
echo "empty"
|
||||
return
|
||||
fi
|
||||
[ -n "$dns" ] || return 1
|
||||
while read -r line; do
|
||||
[[ $line =~ $ip ]] || return 1
|
||||
done <<<"$dns"
|
||||
echo "$dns"
|
||||
}
|
||||
|
||||
result=0
|
||||
# Older releases kept a single backup for whichever service was primary.
|
||||
if [ -f .original_dns.txt ]; then
|
||||
original_dns=$(cat .original_dns.txt)
|
||||
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||
rm -rf .original_dns.txt
|
||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
||||
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
|
||||
result=1
|
||||
elif [ -f ".original_dns/$service_id" ]; then
|
||||
rm -f .original_dns.txt || result=1
|
||||
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
|
||||
result=1
|
||||
fi
|
||||
fi
|
||||
|
||||
for backup in .original_dns/*; do
|
||||
[ -f "$backup" ] || continue
|
||||
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
|
||||
# Kept for a service that is gone: there is nothing to write it back to.
|
||||
[ -n "$hardware_port" ] || continue
|
||||
if ! current_dns=$(dns_of "$hardware_port"); then
|
||||
echo "Cannot read DNS servers of $hardware_port." >&2
|
||||
result=1
|
||||
continue
|
||||
fi
|
||||
# A service no longer on the placeholder was changed by someone else, and that change stands.
|
||||
if [ "$current_dns" = "$1" ]; then
|
||||
original_dns=$(cat "$backup") || { result=1; continue; }
|
||||
# Older releases could back up the placeholder itself.
|
||||
[ "$original_dns" = "$1" ] && original_dns=empty
|
||||
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
|
||||
echo "Failed to restore DNS for $hardware_port." >&2
|
||||
result=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
rm -f "$backup" || result=1
|
||||
done
|
||||
rmdir .original_dns 2>/dev/null
|
||||
exit $result
|
||||
|
||||
@@ -14,22 +14,13 @@ async function resolveUpdater() {
|
||||
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
||||
const github = getOctokit(process.env.GITHUB_TOKEN)
|
||||
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
...options,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
})
|
||||
|
||||
const tag = tags.find((t) => t.name.startsWith('v'))
|
||||
const { data: latestRelease } =
|
||||
await github.rest.repos.getLatestRelease(options)
|
||||
const tag = { name: latestRelease.tag_name }
|
||||
|
||||
console.log(tag)
|
||||
console.log()
|
||||
|
||||
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
|
||||
...options,
|
||||
tag: tag.name,
|
||||
})
|
||||
|
||||
const updateData = {
|
||||
name: tag.name,
|
||||
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
||||
|
||||
+3
-2
@@ -40,10 +40,11 @@ async function resolveUpdater() {
|
||||
const tags = allTags
|
||||
console.log(`Retrieved ${tags.length} tags in total`)
|
||||
|
||||
const stableTagRegex = /^v\d+\.\d+\.\d+$/
|
||||
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
||||
|
||||
const 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))
|
||||
|
||||
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Backend Architecture Guardrails
|
||||
|
||||
These rules supplement the repository's root `AGENTS.md`.
|
||||
|
||||
- **Configuration ownership.** `config` owns source configuration, drafts and
|
||||
persistence. The effects layer owns patch orchestration. Rollbackable
|
||||
transactions publish state after persistence and commit; already applied
|
||||
runtime and independently committed state remain authoritative even when a
|
||||
later step fails. Preserve that state, its notifications and the error.
|
||||
- **Effect ownership.** The declarative registry classifies every Verge patch field,
|
||||
including fields without effects and platform-specific fields. Effects
|
||||
reconcile toward the requested state with explicit dependencies and ordering.
|
||||
Configuration rollback does not imply reversal of OS or lifecycle effects.
|
||||
- **Shared wire contract.** Notifications are typed. Event names, status values
|
||||
and payload shapes stay compatible with frontend producers and consumers.
|
||||
- **Synchronization ownership.** Configuration-write serialization precedes
|
||||
configuration-update ownership, which precedes Core lifecycle ownership.
|
||||
When both are held, the lifecycle lock precedes the DNS lock. Independent
|
||||
transactions retain their ownership boundaries; asynchronous work does not
|
||||
hold synchronous data guards across suspension.
|
||||
- **Retry ownership.** Bounded retries share backend policy while preserving
|
||||
attempts, delays and terminal errors. Background monitors own their lifetime
|
||||
and cancellation separately; waiting for a condition or traversing distinct
|
||||
candidates is not a retry policy.
|
||||
+35
-32
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "clash-verge"
|
||||
version = "2.5.4"
|
||||
version = "2.5.9"
|
||||
description = "clash verge"
|
||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
||||
default-run = "clash-verge"
|
||||
build = "build.rs"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.99"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -29,13 +29,12 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.6.3", features = [] }
|
||||
tauri-build = { version = "2.7.1", features = [] }
|
||||
|
||||
[dependencies]
|
||||
clash-verge-draft = { workspace = true }
|
||||
clash-verge-media-unlock = { workspace = true }
|
||||
clash-verge-logging = { workspace = true }
|
||||
clash-verge-signal = { workspace = true }
|
||||
clash-verge-i18n = { workspace = true }
|
||||
clash-verge-limiter = { workspace = true }
|
||||
tauri-plugin-clash-verge-sysinfo = { workspace = true }
|
||||
@@ -59,14 +58,13 @@ smartstring = { workspace = true, features = ["serde"] }
|
||||
bitflags = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
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"] }
|
||||
dirs = "7.0.0"
|
||||
dunce = "1.0.5"
|
||||
nanoid = "0.5"
|
||||
chrono = { workspace = true }
|
||||
boa_engine = "0.21.1"
|
||||
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||
boa_engine = "0.22.0"
|
||||
percent-encoding = "2.3.2"
|
||||
reqwest = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
@@ -74,51 +72,56 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
||||
"guard",
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
tauri-plugin-window-state = "2.4.1"
|
||||
zip = "8.6.0"
|
||||
flate2 = "1.1.9"
|
||||
tauri-plugin-shell = "2.4.0"
|
||||
tauri-plugin-opener = "2.7.0"
|
||||
tauri-plugin-dialog = "2.8.1"
|
||||
tauri-plugin-fs = "2.6.0"
|
||||
tauri-plugin-process = "2.4.0"
|
||||
tauri-plugin-deep-link = "2.6.1"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "9.0.1"
|
||||
flate2 = "1.1.10"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.0" }
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
base64 = "0.23.1"
|
||||
minisign-verify = "0.3.0"
|
||||
sha2 = "0.11"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.34"
|
||||
gethostname = "1.1.0"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-notification = "2.5.1"
|
||||
tokio-stream = "0.1.19"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.5.9"
|
||||
tauri-plugin-http = "2.8.0"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
||||
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.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",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
tokio-rustls = "0.26.4"
|
||||
rustls = { version = "0.23.43", features = ["ring"] }
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.45", features = ["ring"] }
|
||||
webpki-roots = "1.0.9"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
|
||||
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
|
||||
specta-typescript = "=0.0.12"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.189"
|
||||
libc = "0.2.190"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
|
||||
clash-verge-signal = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
clash-verge-widget = { workspace = true }
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
||||
core-foundation = "0.9"
|
||||
system-configuration = "0.7"
|
||||
objc2 = "0.6.4"
|
||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
||||
objc2 = "0.6.5"
|
||||
objc2-foundation = { version = "0.3.2", features = [
|
||||
"NSString",
|
||||
"NSDictionary",
|
||||
@@ -149,9 +152,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
||||
windows-sys.workspace = true
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "2.5.1"
|
||||
tauri-plugin-global-shortcut = "2.3.2"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
tauri-plugin-autostart = "2.7.0"
|
||||
tauri-plugin-global-shortcut = "2.4.0"
|
||||
tauri-plugin-updater = "2.13.1"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
|
||||
// bundles (PR #8131), so this generator must stay an example, never a bin.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let output = args
|
||||
.next()
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
|
||||
if args.next().is_some() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
|
||||
}
|
||||
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -901,6 +901,7 @@ Section Install
|
||||
|
||||
nsExec::Exec 'netsh int tcp res'
|
||||
|
||||
!include "Win\RestartManager.nsh"
|
||||
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||
!insertmacro CheckAllVergeProcesses
|
||||
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use super::CmdResult;
|
||||
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
|
||||
use crate::{
|
||||
cmd::StringifyErr as _,
|
||||
core::{SilentUpdater, updater::DownloadEvent},
|
||||
feat,
|
||||
utils::dirs,
|
||||
};
|
||||
use smartstring::alias::String;
|
||||
use tauri::{AppHandle, Manager as _};
|
||||
use tauri::{AppHandle, Manager as _, ipc::Channel};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_app_dir() -> CmdResult<()> {
|
||||
let app_dir = dirs::app_home_dir().stringify_err()?;
|
||||
open::that(app_dir).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_core_dir() -> CmdResult<()> {
|
||||
let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
|
||||
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
|
||||
@@ -17,12 +24,14 @@ pub async fn open_core_dir() -> CmdResult<()> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn open_logs_dir() -> CmdResult<()> {
|
||||
let log_dir = dirs::app_logs_dir().stringify_err()?;
|
||||
open::that(log_dir).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn open_devtools(app_handle: AppHandle) {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
if !window.is_devtools_open() {
|
||||
@@ -34,28 +43,61 @@ pub fn open_devtools(app_handle: AppHandle) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn exit_app() {
|
||||
feat::quit().await;
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn restart_app() -> CmdResult<()> {
|
||||
feat::restart_app().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_dir() -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub async fn install_update(
|
||||
app_handle: AppHandle,
|
||||
version: std::string::String,
|
||||
on_event: Channel<DownloadEvent>,
|
||||
) -> CmdResult<bool> {
|
||||
let version: String = version.into();
|
||||
SilentUpdater::global()
|
||||
.install_update(&app_handle, &version, |event| {
|
||||
let _ = on_event.send(event);
|
||||
})
|
||||
.await
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn cancel_update_download() {
|
||||
SilentUpdater::global().cancel_download();
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_app_dir() -> CmdResult<std::string::String> {
|
||||
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
||||
Ok(app_home_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
|
||||
feat::download_icon_cache(url, name).await
|
||||
#[specta::specta]
|
||||
pub async fn download_icon_cache(
|
||||
url: std::string::String,
|
||||
name: std::string::String,
|
||||
) -> CmdResult<std::string::String> {
|
||||
let url: String = url.into();
|
||||
let name: String = name.into();
|
||||
feat::download_icon_cache(url, name).await.map(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
|
||||
feat::copy_icon_file(path, icon_info).await
|
||||
#[specta::specta]
|
||||
pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
|
||||
let path: String = path.into();
|
||||
feat::copy_icon_file(path, icon_info).await.map(Into::into)
|
||||
}
|
||||
|
||||
@@ -4,33 +4,44 @@ use feat::LocalBackupFile;
|
||||
use smartstring::alias::String;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn create_local_backup() -> CmdResult<()> {
|
||||
feat::create_local_backup().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
|
||||
feat::list_local_backup().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::delete_local_backup(filename).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
feat::restore_local_backup(filename)
|
||||
.await
|
||||
.map_err(|error| super::proxy_aware_error(&error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_local_backup(source: String) -> CmdResult<String> {
|
||||
feat::import_local_backup(source).await.stringify_err()
|
||||
#[specta::specta]
|
||||
pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
|
||||
let source: String = source.into();
|
||||
feat::import_local_backup(source).await.map(Into::into).stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
|
||||
#[specta::specta]
|
||||
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
|
||||
let filename: String = filename.into();
|
||||
let destination: String = destination.into();
|
||||
feat::export_local_backup(filename, destination).await.stringify_err()
|
||||
}
|
||||
|
||||
+59
-31
@@ -1,4 +1,5 @@
|
||||
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::feat;
|
||||
use crate::utils::{dirs, yaml_emitter};
|
||||
use crate::{
|
||||
@@ -11,30 +12,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use smartstring::alias::String;
|
||||
use tokio::fs;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn copy_clash_env() -> CmdResult {
|
||||
feat::copy_clash_env().await;
|
||||
Ok(())
|
||||
feat::copy_clash_env().await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
|
||||
Ok(Config::clash().await.data_arc().get_client_info())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
|
||||
feat::patch_clash(&payload)
|
||||
#[specta::specta]
|
||||
pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
|
||||
feat::patch_clash(&payload.0)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
|
||||
let payload: String = payload.into();
|
||||
feat::change_clash_mode(payload)
|
||||
.await
|
||||
.with_error_code("CLASH_MODE_UPDATE_FAILED")
|
||||
@@ -42,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
|
||||
|
||||
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
|
||||
#[tauri::command]
|
||||
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
|
||||
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
|
||||
Ok(Config::clash().await.data_arc().get_mode())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
|
||||
#[specta::specta]
|
||||
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
|
||||
let clash_core: String = clash_core.into();
|
||||
logging!(info, Type::Config, "changing core to {clash_core}");
|
||||
|
||||
match CoreManager::global().change_core(&clash_core).await {
|
||||
@@ -56,14 +63,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
||||
|
||||
match CoreManager::global().restart_core().await {
|
||||
Ok(_) => {
|
||||
handle::Handle::notice_message("config_core::change_success", clash_core);
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
|
||||
handle::Handle::refresh_clash();
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
let failed = err.context("core changed but failed to restart");
|
||||
let error_msg: String = format!("{failed:#}").into();
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
||||
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
@@ -72,13 +79,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
||||
Err(err) => {
|
||||
let error_msg: String = format!("{err:#}").into();
|
||||
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
||||
handle::Handle::notice_message("config_core::change_error", error_msg);
|
||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
||||
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn restart_core() -> CmdResult {
|
||||
logging_error!(Type::Core, profiles_save_file_safe().await);
|
||||
let result = CoreManager::global()
|
||||
@@ -93,6 +101,7 @@ pub async fn restart_core() -> CmdResult {
|
||||
|
||||
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
|
||||
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
|
||||
if report.upgraded {
|
||||
@@ -102,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_delay(url: String) -> CmdResult<u32> {
|
||||
#[specta::specta]
|
||||
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
|
||||
let url: String = url.into();
|
||||
let result = match feat::test_delay(url).await {
|
||||
Ok(delay) => delay,
|
||||
Err(e) => {
|
||||
@@ -114,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
|
||||
use crate::utils::dirs;
|
||||
use tokio::fs;
|
||||
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
|
||||
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
|
||||
fs::write(&dns_path, yaml_str).await.stringify_err()?;
|
||||
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
|
||||
|
||||
@@ -128,42 +140,54 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn take_dns_override_notice() -> bool {
|
||||
crate::config::dns::take_dns_override_notice()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||
feat::set_dns_override(enabled, confirmation)
|
||||
#[specta::specta]
|
||||
pub async fn set_dns_override(
|
||||
profile_uid: std::string::String,
|
||||
enabled: bool,
|
||||
confirmation: Option<std::string::String>,
|
||||
) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||
let profile_uid: String = profile_uid.into();
|
||||
let confirmation = confirmation.map(String::from);
|
||||
feat::set_dns_override(profile_uid, enabled, confirmation)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
if apply {
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
if !dns_path.exists() {
|
||||
logging!(warn, Type::Config, "DNS config file not found");
|
||||
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
|
||||
return Err("DNS config file not found".into());
|
||||
}
|
||||
|
||||
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
||||
logging!(error, Type::Config, "Failed to read DNS config: {e}");
|
||||
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| {
|
||||
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
|
||||
serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||
logging!(
|
||||
error,
|
||||
Type::Config,
|
||||
"Failed to parse DNS config {}: {e}",
|
||||
dns_path.display()
|
||||
);
|
||||
})?;
|
||||
|
||||
let mut patch = serde_yaml_ng::Mapping::new();
|
||||
patch.insert("dns".into(), patch_config.into());
|
||||
|
||||
Config::runtime().await.edit_draft(|d| {
|
||||
d.patch_config(&patch);
|
||||
});
|
||||
|
||||
CoreManager::global().update_config_checked().await.map_err(|error| {
|
||||
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
|
||||
proxy_aware_error(&error)
|
||||
@@ -187,20 +211,22 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_dns_config_content() -> CmdResult<Option<String>> {
|
||||
#[specta::specta]
|
||||
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
|
||||
use crate::utils::dirs;
|
||||
use tokio::fs;
|
||||
|
||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||
|
||||
match fs::read_to_string(&dns_path).await {
|
||||
Ok(content) => Ok(Some(content.into())),
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(CommandFailure::plain(error)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
let app_dir = dirs::app_home_dir().stringify_err()?;
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
@@ -210,12 +236,14 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
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
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
|
||||
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
|
||||
Ok(logs)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::module::lightweight;
|
||||
use crate::core::lightweight;
|
||||
|
||||
use super::CmdResult;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn entry_lightweight_mode() -> CmdResult {
|
||||
lightweight::entry_lightweight_mode().await;
|
||||
Ok(())
|
||||
|
||||
@@ -5,11 +5,13 @@ use crate::{
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
|
||||
feat::probe_listener(request).await.stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
|
||||
feat::save_proxy_ports(settings)
|
||||
.await
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
use tauri::{command, ipc::Channel};
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub fn get_unlock_items() -> Vec<UnlockItem> {
|
||||
clash_verge_media_unlock::default_unlock_items()
|
||||
}
|
||||
@@ -26,6 +27,7 @@ async fn create_client() -> Result<Client, String> {
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
|
||||
let client = create_client().await?;
|
||||
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
|
||||
@@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[specta::specta]
|
||||
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
|
||||
let client = create_client().await?;
|
||||
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
|
||||
|
||||
@@ -4,7 +4,7 @@ use smartstring::alias::String;
|
||||
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
|
||||
|
||||
/// Structured command failure returned to the frontend.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
pub struct CommandFailure {
|
||||
/// Stable frontend identifier, when classified.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _;
|
||||
use crate::core::{proxy_control, sysopt::Sysopt};
|
||||
|
||||
use gethostname::gethostname;
|
||||
use network_interface::NetworkInterface;
|
||||
use serde_yaml_ng::Mapping;
|
||||
use sysproxy::{Autoproxy, Sysproxy};
|
||||
use tauri_plugin_clash_verge_sysinfo;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||
#[specta::specta]
|
||||
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
|
||||
Sysopt::global().wait_idle().await;
|
||||
// With no network service there is no proxy configured anywhere, which reads as disabled.
|
||||
let sys_proxy = match Sysproxy::get_system_proxy() {
|
||||
@@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
|
||||
other => other.stringify_err()?,
|
||||
};
|
||||
let Sysproxy {
|
||||
ref host,
|
||||
ref bypass,
|
||||
ref port,
|
||||
ref enable,
|
||||
host,
|
||||
bypass,
|
||||
port,
|
||||
enable,
|
||||
} = sys_proxy;
|
||||
|
||||
let mut map = Mapping::new();
|
||||
map.insert("enable".into(), (*enable).into());
|
||||
map.insert("server".into(), format!("{}:{}", host, port).into());
|
||||
map.insert("bypass".into(), bypass.as_str().into());
|
||||
Ok(map)
|
||||
Ok(SystemProxy {
|
||||
enable,
|
||||
server: format!("{host}:{port}"),
|
||||
bypass,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
|
||||
#[specta::specta]
|
||||
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
|
||||
Sysopt::global().wait_idle().await;
|
||||
let auto_proxy = match Autoproxy::get_auto_proxy() {
|
||||
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
|
||||
other => other.stringify_err()?,
|
||||
};
|
||||
let Autoproxy { ref enable, ref url } = auto_proxy;
|
||||
let Autoproxy { enable, url } = auto_proxy;
|
||||
|
||||
let mut map = Mapping::new();
|
||||
map.insert("enable".into(), (*enable).into());
|
||||
map.insert("url".into(), url.as_str().into());
|
||||
Ok(map)
|
||||
Ok(AutoProxy { enable, url })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_embedded_server_port() -> CmdResult<u16> {
|
||||
crate::utils::server::embedded_server_port().stringify_err()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_system_hostname() -> String {
|
||||
match gethostname().into_string() {
|
||||
Ok(name) => name,
|
||||
@@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_network_interfaces() -> Vec<String> {
|
||||
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
#[specta::specta]
|
||||
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
|
||||
|
||||
let names = get_network_interfaces();
|
||||
@@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
|
||||
|
||||
for interface in interfaces {
|
||||
if names.contains(&interface.name) {
|
||||
result.push(interface);
|
||||
result.push(interface.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct SystemProxy {
|
||||
pub enable: bool,
|
||||
pub server: String,
|
||||
pub bypass: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct AutoProxy {
|
||||
pub enable: bool,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
+116
-18
@@ -1,6 +1,7 @@
|
||||
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
||||
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
||||
use crate::config::profiles;
|
||||
use crate::core::notify::NoticeStatus;
|
||||
use crate::utils::window_manager::WindowManager;
|
||||
use crate::{
|
||||
config::{
|
||||
@@ -32,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
||||
let draft = Config::profiles().await;
|
||||
let data = draft.data_arc();
|
||||
@@ -39,6 +41,7 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
match feat::enhance_profiles().await {
|
||||
Ok(outcome) if outcome.is_valid() => {
|
||||
@@ -64,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
|
||||
#[specta::specta]
|
||||
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
||||
Ok(it) => it,
|
||||
@@ -93,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
|
||||
let active_id: String = active_id.into();
|
||||
let over_id: String = over_id.into();
|
||||
match profiles_reorder_safe(&active_id, &over_id).await {
|
||||
Ok(_) => {
|
||||
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
|
||||
@@ -113,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
|
||||
let item: PrfItem = item.into();
|
||||
let file_data = file_data.map(String::from);
|
||||
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
||||
Ok(_) => {
|
||||
profiles_save_file_safe()
|
||||
.await
|
||||
.with_error_code("PROFILE_CREATE_FAILED")?;
|
||||
logging_error!(Type::Timer, Timer::global().refresh().await);
|
||||
if let Some(uid) = &item.uid {
|
||||
handle::Handle::notify_profile_changed(uid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
|
||||
@@ -130,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
||||
let index: String = index.into();
|
||||
match feat::update_profile(&index, option.as_ref(), true).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
@@ -141,7 +150,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile(index: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn delete_profile(index: std::string::String) -> CmdResult {
|
||||
let index: String = index.into();
|
||||
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let profiles = Config::profiles().await;
|
||||
@@ -282,7 +293,7 @@ async fn handle_update_error<E: std::fmt::Display>(
|
||||
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
||||
discard_and_restore(current_profile).await?;
|
||||
let message: String = e.to_string().into();
|
||||
handle::Handle::notice_message("config_validate::boot_error", message.clone());
|
||||
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
|
||||
Ok(ValidationOutcome::invalid_from_message(message))
|
||||
}
|
||||
|
||||
@@ -312,9 +323,85 @@ async fn perform_config_update(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileSelection {
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub current: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfileCreate {
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub itype: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub desc: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
pub option: Option<PrfOption>,
|
||||
}
|
||||
|
||||
impl From<ProfileCreate> for PrfItem {
|
||||
fn from(item: ProfileCreate) -> Self {
|
||||
Self {
|
||||
itype: item.itype,
|
||||
name: item.name,
|
||||
desc: item.desc,
|
||||
url: item.url,
|
||||
option: item.option,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Deserialize, specta::Type)]
|
||||
#[serde(default)]
|
||||
pub struct ProfilePatch {
|
||||
#[serde(rename = "type")]
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub itype: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub name: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub desc: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub file: Option<String>,
|
||||
#[specta(type = Option<std::string::String>)]
|
||||
pub url: Option<String>,
|
||||
pub selected: Option<Vec<crate::config::PrfSelected>>,
|
||||
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
|
||||
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
||||
pub extra: Option<crate::config::PrfExtra>,
|
||||
pub updated: Option<usize>,
|
||||
pub option: Option<PrfOption>,
|
||||
}
|
||||
|
||||
impl From<ProfilePatch> for PrfItem {
|
||||
fn from(patch: ProfilePatch) -> Self {
|
||||
Self {
|
||||
itype: patch.itype,
|
||||
name: patch.name,
|
||||
desc: patch.desc,
|
||||
file: patch.file,
|
||||
url: patch.url,
|
||||
selected: patch.selected,
|
||||
latency_test_urls: patch.latency_test_urls,
|
||||
extra: patch.extra,
|
||||
updated: patch.updated,
|
||||
option: patch.option,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
|
||||
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
|
||||
#[specta::specta]
|
||||
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
|
||||
if CURRENT_SWITCHING_PROFILE
|
||||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
@@ -324,11 +411,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
|
||||
}
|
||||
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let profiles = IProfiles {
|
||||
current: profiles.current,
|
||||
items: None,
|
||||
};
|
||||
let target_profile = profiles.current.as_ref();
|
||||
|
||||
let previous_profile = Config::profiles().await.data_arc().current.clone();
|
||||
|
||||
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
|
||||
Config::profiles().await.edit_draft(|d| {
|
||||
d.patch_config(&profiles);
|
||||
});
|
||||
|
||||
perform_config_update(target_profile, previous_profile.as_ref())
|
||||
.await
|
||||
@@ -336,15 +429,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
|
||||
}
|
||||
|
||||
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
|
||||
let profiles = IProfiles {
|
||||
let profiles = ProfileSelection {
|
||||
current: Some(profile_index),
|
||||
items: None,
|
||||
};
|
||||
patch_profiles_config(profiles).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
|
||||
let profile: PrfItem = profile.into();
|
||||
let index: String = index.into();
|
||||
let profiles = Config::profiles().await;
|
||||
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
|
||||
&& let Some(new_option) = profile.option.as_ref()
|
||||
@@ -384,7 +479,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn view_profile(index: String) -> CmdResult {
|
||||
#[specta::specta]
|
||||
pub async fn view_profile(index: std::string::String) -> CmdResult {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
let file = profiles_ref
|
||||
@@ -408,7 +504,8 @@ pub async fn view_profile(index: String) -> CmdResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
#[specta::specta]
|
||||
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
|
||||
let item = {
|
||||
let profiles = Config::profiles().await;
|
||||
let profiles_ref = profiles.latest_arc();
|
||||
@@ -428,7 +525,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
.join(file.as_str());
|
||||
match tokio::fs::try_exists(&path).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(String::new()),
|
||||
Ok(false) => return Ok(std::string::String::new()),
|
||||
Err(err) => {
|
||||
return Err(coded_error(
|
||||
"PROFILE_READ_FAILED",
|
||||
@@ -439,11 +536,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
||||
}
|
||||
|
||||
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
|
||||
Ok(data)
|
||||
Ok(data.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
||||
#[specta::specta]
|
||||
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
|
||||
let timer = Timer::global();
|
||||
let next_time = timer.get_next_update_time(&uid).await;
|
||||
Ok(next_time)
|
||||
|
||||
+76
-32
@@ -4,7 +4,7 @@ use crate::{
|
||||
config::Config,
|
||||
core::{
|
||||
handle::Handle,
|
||||
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||
tray::Tray,
|
||||
},
|
||||
process::AsyncHandler,
|
||||
@@ -12,12 +12,13 @@ use crate::{
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use serde_yaml_ng::Mapping;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
collections::{BTreeMap, HashSet},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
|
||||
crate::config::profiles::record_selected_node(&group_name, &node)
|
||||
.await
|
||||
@@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
|
||||
crate::config::profiles::forget_selected_node(&group_name)
|
||||
.await
|
||||
@@ -50,24 +52,93 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
|
||||
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
|
||||
let Some(config) = config else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let mut all_providers = config
|
||||
.get("proxy-providers")
|
||||
.and_then(|providers| providers.as_mapping())
|
||||
.into_iter()
|
||||
.flat_map(|providers| providers.keys())
|
||||
.filter_map(|name| name.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
all_providers.sort();
|
||||
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
|
||||
|
||||
config
|
||||
.get("proxy-groups")
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|group| group.as_mapping())
|
||||
.filter_map(|group| {
|
||||
let name = group.get("name")?.as_str()?.to_owned();
|
||||
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
|
||||
all_providers.clone()
|
||||
} else {
|
||||
group
|
||||
.get("use")
|
||||
.and_then(|providers| providers.as_sequence())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|provider| provider.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
let exclude_types = group
|
||||
.get("exclude-type")
|
||||
.and_then(|types| types.as_str())
|
||||
.filter(|types| !types.is_empty())
|
||||
.map(|types| types.split('|').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
Some((
|
||||
name,
|
||||
GroupScope {
|
||||
providers,
|
||||
exclude_types,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
||||
proxy_view(None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
|
||||
let runtime = Config::runtime().await;
|
||||
let latest_runtime = runtime.latest_arc();
|
||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
||||
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
|
||||
|
||||
let mihomo = Handle::mihomo();
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
|
||||
match provider_timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok),
|
||||
None => mihomo.get_proxy_providers().await.ok(),
|
||||
}
|
||||
});
|
||||
let proxies = proxies.stringify_err()?;
|
||||
|
||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||
runtime_group_order,
|
||||
group_scopes,
|
||||
proxies,
|
||||
providers: providers.ok(),
|
||||
providers,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
||||
if TRAY_SYNC_RUNNING
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
@@ -84,6 +155,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
||||
}
|
||||
|
||||
async fn run_tray_sync_loop() {
|
||||
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
|
||||
loop {
|
||||
match Tray::global().update_menu().await {
|
||||
Ok(_) => {
|
||||
@@ -109,31 +181,3 @@ async fn run_tray_sync_loop() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used)]
|
||||
mod tests {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
use super::runtime_group_order;
|
||||
|
||||
#[test]
|
||||
fn runtime_order_keeps_first_non_empty_non_global_name() {
|
||||
let config: Value = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
proxy-groups:
|
||||
- name: Beta
|
||||
- name: ""
|
||||
- name: GLOBAL
|
||||
- name: " Alpha "
|
||||
- name: Beta
|
||||
"#,
|
||||
)
|
||||
.expect("parse runtime");
|
||||
|
||||
assert_eq!(
|
||||
runtime_group_order(config.as_mapping()),
|
||||
["Beta".to_owned(), " Alpha ".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user