mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-30 10:42:09 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ad95657bd | ||
|
|
a544990f9f | ||
|
|
e35a239a06 | ||
|
|
143ed48aa9 |
@@ -5,10 +5,10 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:2": {
|
"ghcr.io/devcontainers/features/node:2": {
|
||||||
"version": "24.21.0",
|
"version": "24.21.0",
|
||||||
"pnpmVersion": "12.10.0"
|
"pnpmVersion": "12.3.3"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/rust:1": {
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
"version": "1.99.0",
|
"version": "1.98.1",
|
||||||
"profile": "default"
|
"profile": "default"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/git:1": {},
|
"ghcr.io/devcontainers/features/git:1": {},
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"version": "v9.0.0",
|
"version": "v9.0.0",
|
||||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||||
},
|
},
|
||||||
"github/gh-aw-actions/setup@v0.89.21": {
|
"github/gh-aw-actions/setup@v0.89.1": {
|
||||||
"repo": "github/gh-aw-actions/setup",
|
"repo": "github/gh-aw-actions/setup",
|
||||||
"version": "v0.89.21",
|
"version": "v0.89.1",
|
||||||
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
|
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"containers": {
|
"containers": {
|
||||||
@@ -22,16 +22,6 @@
|
|||||||
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
|
|
||||||
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
|
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
|
|
||||||
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
|
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
|
||||||
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
|
||||||
@@ -42,16 +32,6 @@
|
|||||||
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
|
|
||||||
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
|
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
|
|
||||||
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
|
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
|
||||||
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
|
||||||
@@ -62,30 +42,20 @@
|
|||||||
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
|
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
|
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
|
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
|
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
|
||||||
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
|
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
|
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
|
|
||||||
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
|
|
||||||
},
|
|
||||||
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
|
|
||||||
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
|
|
||||||
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
|
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
|
|
||||||
},
|
},
|
||||||
"ghcr.io/github/gh-aw-node": {
|
"ghcr.io/github/gh-aw-node": {
|
||||||
"image": "ghcr.io/github/gh-aw-node",
|
"image": "ghcr.io/github/gh-aw-node",
|
||||||
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
|
||||||
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
|
||||||
},
|
},
|
||||||
"ghcr.io/github/github-mcp-server:v1.12.2": {
|
"ghcr.io/github/github-mcp-server:v1.12.1": {
|
||||||
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
|
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
|
||||||
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
|
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
|
||||||
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
|
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,19 +30,11 @@ jobs:
|
|||||||
PR_BODY: ${{ github.event.pull_request.body }}
|
PR_BODY: ${{ github.event.pull_request.body }}
|
||||||
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||||
DRAFT: ${{ github.event.pull_request.draft }}
|
DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
|
||||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
|
||||||
echo "Draft or no-issue-needed; linkage check skipped."
|
echo "Draft or no-issue-needed; linkage check skipped."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
|
|
||||||
# skip-roles/skip-bots trust tier.
|
|
||||||
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
|
||||||
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
|
||||||
echo "Issue reference found."
|
echo "Issue reference found."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ name: Auto Build
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
|
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
|
||||||
- cron: '0 10,22 * * *'
|
- cron: '0 4,10 * * *'
|
||||||
permissions: write-all
|
permissions: write-all
|
||||||
env:
|
env:
|
||||||
TAG_NAME: autobuild
|
TAG_NAME: autobuild
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.99.0'
|
toolchain: '1.98.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.99.0'
|
toolchain: '1.98.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -347,7 +347,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Backend Guardrails
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "src-tauri/**"
|
|
||||||
- ".github/workflows/backend-guardrails.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
banned-patterns:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: No Handle::refresh calls in feat
|
|
||||||
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
|
|
||||||
|
|
||||||
- name: No notice_message outside core/notify
|
|
||||||
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
|
|
||||||
|
|
||||||
- name: No manual update-flag chains
|
|
||||||
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
|
|
||||||
@@ -31,6 +31,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install gh-aw extension
|
- name: Install gh-aw extension
|
||||||
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
|
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
|
||||||
with:
|
with:
|
||||||
version: v0.89.15
|
version: v0.89.1
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
uses: dtolnay/rust-toolchain@1.99.0
|
uses: dtolnay/rust-toolchain@1.98.1
|
||||||
|
|
||||||
- name: Rust Cache
|
- name: Rust Cache
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: Swatinem/rust-cache@v2
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
if: github.event.inputs[matrix.input] == 'true'
|
if: github.event.inputs[matrix.input] == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
|
|||||||
@@ -85,82 +85,3 @@ jobs:
|
|||||||
- name: Run Knip
|
- name: Run Knip
|
||||||
if: steps.check_frontend.outputs.frontend == 'true'
|
if: steps.check_frontend.outputs.frontend == 'true'
|
||||||
run: pnpm knip:check
|
run: pnpm knip:check
|
||||||
|
|
||||||
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
|
|
||||||
if: steps.check_frontend.outputs.frontend == 'true'
|
|
||||||
run: node scripts/check-architecture.mjs listen emit invoke
|
|
||||||
|
|
||||||
contract-parity:
|
|
||||||
runs-on: ubuntu-26.04
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Check contract inputs
|
|
||||||
id: check_inputs
|
|
||||||
uses: dorny/paths-filter@v4
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
contract:
|
|
||||||
- 'src/services/contract.ts'
|
|
||||||
- 'scripts/gen-contract.mjs'
|
|
||||||
- 'src-tauri/src/core/notify/**'
|
|
||||||
- 'src-tauri/src/bin/**'
|
|
||||||
- 'src-tauri/src/lib.rs'
|
|
||||||
- '.github/workflows/frontend-check.yml'
|
|
||||||
|
|
||||||
- name: Skip if contract inputs unchanged
|
|
||||||
if: steps.check_inputs.outputs.contract != 'true'
|
|
||||||
run: echo "No contract inputs changed, skipping parity check."
|
|
||||||
|
|
||||||
- name: Install Rust Stable
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
uses: dtolnay/rust-toolchain@master
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
|
|
||||||
- name: Rust Cache
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
prefix-key: 'v1-rust'
|
|
||||||
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
|
|
||||||
workspaces: |
|
|
||||||
. -> target
|
|
||||||
cache-all-crates: true
|
|
||||||
cache-workspace-crates: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
uses: pnpm/action-setup@v6
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
with:
|
|
||||||
node-version: '24.21.0'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
|
|
||||||
- name: Provision sidecar placeholders for tauri-build
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
run: |
|
|
||||||
mkdir -p src-tauri/sidecar src-tauri/resources dist
|
|
||||||
touch dist/index.html
|
|
||||||
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
|
|
||||||
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Regenerate contract and require empty diff
|
|
||||||
if: steps.check_inputs.outputs.contract == 'true'
|
|
||||||
run: |
|
|
||||||
pnpm gen:contract
|
|
||||||
git diff --exit-code -- src/services/contract.ts
|
|
||||||
|
|||||||
+127
-171
File diff suppressed because one or more lines are too long
@@ -144,7 +144,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.99.0'
|
toolchain: '1.98.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -244,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.99.0'
|
toolchain: '1.98.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -389,7 +389,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -446,7 +446,7 @@ jobs:
|
|||||||
- name: Install Rust Stable
|
- name: Install Rust Stable
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: '1.99.0'
|
toolchain: '1.98.1'
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
@@ -487,7 +487,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Add Rust Target
|
- name: Add Rust Target
|
||||||
run: |
|
run: |
|
||||||
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
|
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
|
||||||
rustup target list --installed
|
rustup target list --installed
|
||||||
echo "Rust target ${{ matrix.target }} installed."
|
echo "Rust target ${{ matrix.target }} installed."
|
||||||
|
|
||||||
@@ -691,7 +691,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
|
||||||
|
|
||||||
### 稳定机场VPN推荐
|
### 稳定机场VPN推荐
|
||||||
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
Created at ${{ env.BUILDTIME }}.
|
Created at ${{ env.BUILDTIME }}.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ target
|
|||||||
.vfox
|
.vfox
|
||||||
.claude
|
.claude
|
||||||
.zed
|
.zed
|
||||||
|
/src-tauri/generated/widget/
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
nodejs 24.21.0
|
nodejs 24.21.0
|
||||||
pnpm 12.10.0
|
pnpm 12.4.1
|
||||||
rust 1.99.0
|
rust 1.98.1
|
||||||
|
|||||||
Generated
+788
-694
File diff suppressed because it is too large
Load Diff
+10
-24
@@ -8,6 +8,7 @@ members = [
|
|||||||
"crates/tauri-plugin-clash-verge-sysinfo",
|
"crates/tauri-plugin-clash-verge-sysinfo",
|
||||||
"crates/clash-verge-i18n",
|
"crates/clash-verge-i18n",
|
||||||
"crates/clash-verge-limiter",
|
"crates/clash-verge-limiter",
|
||||||
|
"crates/clash-verge-widget",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -46,15 +47,6 @@ inherits = "fast-release"
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
split-debuginfo = "unpacked"
|
split-debuginfo = "unpacked"
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
|
|
||||||
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
|
|
||||||
|
|
||||||
# macOS Golden Gate fix for tray-icon click reentrancy issue
|
|
||||||
# Remove the following patch once the fix is merged upstream(tauri)
|
|
||||||
# https://github.com/tauri-apps/tray-icon/pull/365
|
|
||||||
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
clash-verge-draft = { path = "crates/clash-verge-draft" }
|
||||||
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
|
||||||
@@ -62,32 +54,27 @@ clash-verge-logging = { path = "crates/clash-verge-logging" }
|
|||||||
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
clash-verge-signal = { path = "crates/clash-verge-signal" }
|
||||||
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
|
||||||
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
|
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" }
|
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
|
||||||
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1"
|
||||||
|
|
||||||
tauri = { version = "2.12.1" }
|
tauri = { version = "2.11.5" }
|
||||||
tauri-plugin-clipboard-manager = "2.4.1"
|
tauri-plugin-clipboard-manager = "2.3.2"
|
||||||
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
chrono = "0.4.45"
|
chrono = "0.4.45"
|
||||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||||
tokio = { version = "1.53.2", features = [
|
tokio = { version = "1.53.1", features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"macros",
|
"macros",
|
||||||
"time",
|
"time",
|
||||||
"sync",
|
"sync",
|
||||||
] }
|
] }
|
||||||
flexi_logger = "0.31.10"
|
flexi_logger = "0.31.10"
|
||||||
log = "0.4.34"
|
log = "0.4.33"
|
||||||
regex = "1.13.1"
|
regex = "1.13.1"
|
||||||
reqwest = { version = "0.13.5", features = [
|
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
|
||||||
"json",
|
|
||||||
"cookies",
|
|
||||||
"gzip",
|
|
||||||
"rustls",
|
|
||||||
"form",
|
|
||||||
] }
|
|
||||||
rust_iso3166 = "0.2.0"
|
rust_iso3166 = "0.2.0"
|
||||||
|
|
||||||
smartstring = { version = "1.0.1" }
|
smartstring = { version = "1.0.1" }
|
||||||
@@ -95,7 +82,7 @@ smartstring = { version = "1.0.1" }
|
|||||||
serde = { version = "1.0.229" }
|
serde = { version = "1.0.229" }
|
||||||
serde_json = { version = "1.0.151" }
|
serde_json = { version = "1.0.151" }
|
||||||
serde_yaml_ng = { version = "0.10.0" }
|
serde_yaml_ng = { version = "0.10.0" }
|
||||||
bitflags = { version = "2.13.2" }
|
bitflags = { version = "2.13.1" }
|
||||||
socket2 = "0.6.5"
|
socket2 = "0.6.5"
|
||||||
|
|
||||||
# *** For Windows platform only ***
|
# *** For Windows platform only ***
|
||||||
@@ -108,8 +95,7 @@ windows-sys = { version = "0.61.2", features = [
|
|||||||
"Win32_Security_Authorization",
|
"Win32_Security_Authorization",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_System_Diagnostics_ToolHelp",
|
"Win32_System_JobObjects"
|
||||||
"Win32_System_JobObjects",
|
|
||||||
] }
|
] }
|
||||||
# *********************************
|
# *********************************
|
||||||
|
|
||||||
|
|||||||
+71
-22
@@ -1,51 +1,100 @@
|
|||||||
## v2.5.8
|
## v2.5.4
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||||
|
|
||||||
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
|
- 修复系统代理切换失败后,开关状态显示错误的问题
|
||||||
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
|
- 修复代理守卫失败后反复重试且没有提示的问题
|
||||||
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
|
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
||||||
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
|
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
||||||
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
||||||
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
|
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
||||||
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
|
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
||||||
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
|
- 修复重启内核后未恢复上次所选节点的问题
|
||||||
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
|
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
||||||
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
|
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
||||||
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
|
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
||||||
|
- 修复切换节点时应用可能卡住的问题
|
||||||
|
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
||||||
|
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
||||||
|
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
||||||
|
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
||||||
|
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
||||||
|
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
|
- 修复 Windows 系统代理设置偶发失败的问题
|
||||||
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
|
- 修复 Windows 关机被应用阻止的问题
|
||||||
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
|
|
||||||
|
|
||||||
**🍎 macOS**
|
**🍎 macOS**
|
||||||
|
|
||||||
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
|
- 修复 macOS 内核更新失败后无法启动的问题
|
||||||
|
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
||||||
|
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
||||||
|
|
||||||
**🖥️/🍎 Windows/macOS**
|
**🐧 Linux**
|
||||||
|
|
||||||
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
|
- 修复 Linux 安装或卸载服务时授权失败的问题
|
||||||
|
|
||||||
|
**🍎/🐧 macOS/Linux**
|
||||||
|
|
||||||
|
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
||||||
|
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||||
|
|
||||||
- 新增系统托盘代理组中的「测试延迟」菜单项
|
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
||||||
|
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
||||||
|
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
||||||
|
- 新增手动取消节点固定功能
|
||||||
|
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
||||||
|
- 新增前置规则与后置规则互相移动功能
|
||||||
|
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
||||||
|
- 新增代理页排查提示,方便检查订阅和内核状态
|
||||||
|
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
||||||
|
- 新增启动失败提示,显示原因并保存启动日志
|
||||||
|
|
||||||
|
**🍎 macOS**
|
||||||
|
|
||||||
|
- 新增 macOS Liquid Glass 风格图标
|
||||||
|
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||||
|
|
||||||
- 优化端口设置窗口的实现,精简重复代码
|
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||||
- 优化侧边栏流量图表的 CPU 占用
|
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||||
|
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||||
|
- 优化代理列表刷新、排序和页面切换速度
|
||||||
|
- 优化首页节点操作,显示节点固定状态
|
||||||
|
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
||||||
|
- 优化更新说明排版,支持表格和提示框
|
||||||
|
- 优化搜索框聚焦、界面字体和拖动排序动画
|
||||||
|
- 优化后台连接和托盘刷新,降低资源占用
|
||||||
|
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
||||||
|
- 优化日志记录,减少重复信息
|
||||||
|
- 移除便携模式,统一使用系统应用数据目录
|
||||||
|
- 优化繁体中文、俄语翻译及语言名称显示
|
||||||
|
|
||||||
**🖥️ Windows**
|
**🖥️ Windows**
|
||||||
|
|
||||||
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
|
- 优化 Windows 自动更新,跳过安装语言选择
|
||||||
|
|
||||||
|
**🐧 Linux**
|
||||||
|
|
||||||
|
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Languages:
|
Languages:
|
||||||
|
<a href="./README.md">简体中文</a> ·
|
||||||
<a href="./docs/README_en.md">English</a> ·
|
<a href="./docs/README_en.md">English</a> ·
|
||||||
<a href="./docs/README_es.md">Español</a> ·
|
<a href="./docs/README_es.md">Español</a> ·
|
||||||
<a href="./docs/README_fa.md">فارسی</a> ·
|
<a href="./docs/README_ru.md">Русский</a> ·
|
||||||
<a href="./docs/README_ja.md">日本語</a> ·
|
<a href="./docs/README_ja.md">日本語</a> ·
|
||||||
<a href="./docs/README_ko.md">한국어</a> ·
|
<a href="./docs/README_ko.md">한국어</a> ·
|
||||||
<a href="./docs/README_pt.md">Português</a> ·
|
<a href="./docs/README_fa.md">فارسی</a>
|
||||||
<a href="./docs/README_ru.md">Русский</a> ·
|
|
||||||
<a href="./README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
@@ -32,7 +31,6 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
|
|||||||
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
|
请到发布页面下载对应的安装包:[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>
|
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).
|
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
||||||
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
|
|
||||||
|
|
||||||
#### 我应当怎样选择发行版
|
#### 我应当怎样选择发行版
|
||||||
|
|
||||||
@@ -52,14 +50,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
|
|||||||
|
|
||||||
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
|
### ✈️ [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云边 -- 全新架构机场。
|
||||||
|
|
||||||
- 💻 多次**技术迭代后**全新亮相。
|
- 💻 多次**技术迭代后**全新亮相。
|
||||||
- 🗺 全**高速稳定**正价节点。
|
- 🗺 全**高速稳定**正价节点。
|
||||||
- 🌏 **海外团队**,不跑路
|
- 🌏 **海外团队**,不跑路
|
||||||
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
|
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
|
||||||
- 👨🦲 团队架构师为**大厂**网络架构师
|
- 👨🦲 团队架构师为**大厂**网络架构师
|
||||||
- 💰 极致**稳定**,亲民价**价格**
|
- 💰 极致**稳定**,亲民价**价格**
|
||||||
- 🌐 全面支持**流媒体及各AI访问**
|
- 🌐 全面支持**流媒体及各AI访问**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rust-i18n = "4.2.4"
|
rust-i18n = "4.2.1"
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: على وشك الخروج
|
title: على وشك الخروج
|
||||||
body: Clash Verge على وشك الخروج.
|
body: Clash Verge على وشك الخروج.
|
||||||
|
appHidden:
|
||||||
|
title: تم إخفاء التطبيق
|
||||||
|
body: Clash Verge يعمل في الخلفية.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||||
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
|
||||||
core:
|
|
||||||
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
|
||||||
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: عام
|
global: عام
|
||||||
profiles: الملفات التعريفية
|
profiles: الملفات التعريفية
|
||||||
proxies: وكلاء
|
proxies: وكلاء
|
||||||
testLatency: اختبار التأخير
|
|
||||||
testingLatency: جارٍ اختبار التأخير…
|
|
||||||
systemProxy: وكيل النظام
|
systemProxy: وكيل النظام
|
||||||
tunMode: وضع TUN
|
tunMode: وضع TUN
|
||||||
closeAllConnections: إغلاق كل الاتصالات
|
closeAllConnections: إغلاق كل الاتصالات
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: وكيل النظام
|
systemProxy: وكيل النظام
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: ملف تعريفي
|
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.
|
body: Auf {mode} umgeschaltet.
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Systemproxy
|
title: Systemproxy
|
||||||
'on': Der Systemproxy wurde aktiviert.
|
'on': System proxy has been enabled.
|
||||||
'off': Der Systemproxy wurde deaktiviert.
|
'off': System proxy has been disabled.
|
||||||
systemProxyFailed:
|
systemProxyFailed:
|
||||||
title: Systemproxy
|
title: Systemproxy
|
||||||
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
|
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.
|
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN-Modus
|
title: TUN-Modus
|
||||||
'on': Der TUN-Modus wurde aktiviert.
|
'on': TUN mode has been enabled.
|
||||||
'off': Der TUN-Modus wurde deaktiviert.
|
'off': TUN mode has been disabled.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Leichtmodus
|
title: Leichtmodus
|
||||||
body: Leichtmodus aktiviert.
|
body: Leichtmodus aktiviert.
|
||||||
@@ -29,16 +29,17 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Beenden steht bevor
|
title: Beenden steht bevor
|
||||||
body: Clash Verge wird gleich beendet.
|
body: Clash Verge wird gleich beendet.
|
||||||
|
appHidden:
|
||||||
|
title: Anwendung ausgeblendet
|
||||||
|
body: Clash Verge läuft im Hintergrund.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash-Verge-Update
|
title: Clash Verge Update
|
||||||
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
installNow: Jetzt installieren
|
installNow: Install Now
|
||||||
later: Später
|
later: Later
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
|
||||||
adminUninstallPrompt: Für die Deinstallation 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:
|
launchGuard:
|
||||||
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
|
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}'
|
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profile
|
profiles: Profile
|
||||||
proxies: Proxy
|
proxies: Proxy
|
||||||
testLatency: Latenz testen
|
|
||||||
testingLatency: Latenz wird getestet…
|
|
||||||
systemProxy: Systemproxy
|
systemProxy: Systemproxy
|
||||||
tunMode: TUN-Modus
|
tunMode: TUN-Modus
|
||||||
closeAllConnections: Alle Verbindungen schließen
|
closeAllConnections: Alle Verbindungen schließen
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Systemproxy
|
systemProxy: Systemproxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
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,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: About to Exit
|
title: About to Exit
|
||||||
body: Clash Verge is about to exit.
|
body: Clash Verge is about to exit.
|
||||||
|
appHidden:
|
||||||
|
title: Application Hidden
|
||||||
|
body: Clash Verge is running in the background.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
|
||||||
adminUninstallPrompt: Uninstalling 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:
|
launchGuard:
|
||||||
currentExeError: 'Unable to determine the application location: {error}'
|
currentExeError: 'Unable to determine the application location: {error}'
|
||||||
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profiles
|
profiles: Profiles
|
||||||
proxies: Proxies
|
proxies: Proxies
|
||||||
testLatency: Test Latency
|
|
||||||
testingLatency: Testing latency…
|
|
||||||
systemProxy: System Proxy
|
systemProxy: System Proxy
|
||||||
tunMode: TUN Mode
|
tunMode: TUN Mode
|
||||||
closeAllConnections: Close All Connections
|
closeAllConnections: Close All Connections
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: System Proxy
|
systemProxy: System Proxy
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profile
|
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,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: A punto de salir
|
title: A punto de salir
|
||||||
body: Clash Verge está a punto de salir.
|
body: Clash Verge está a punto de salir.
|
||||||
|
appHidden:
|
||||||
|
title: Aplicación oculta
|
||||||
|
body: Clash Verge se está ejecutando en segundo plano.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
|
||||||
adminUninstallPrompt: Desinstalar 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:
|
launchGuard:
|
||||||
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
|
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}'
|
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Perfiles
|
profiles: Perfiles
|
||||||
proxies: Proxies
|
proxies: Proxies
|
||||||
testLatency: Probar latencia
|
|
||||||
testingLatency: Probando latencia…
|
|
||||||
systemProxy: Proxy del sistema
|
systemProxy: Proxy del sistema
|
||||||
tunMode: Modo TUN
|
tunMode: Modo TUN
|
||||||
closeAllConnections: Cerrar todas las conexiones
|
closeAllConnections: Cerrar todas las conexiones
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Proxy del sistema
|
systemProxy: Proxy del sistema
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Perfil
|
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,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: در آستانه خروج
|
title: در آستانه خروج
|
||||||
body: Clash Verge در آستانه خروج است.
|
body: Clash Verge در آستانه خروج است.
|
||||||
|
appHidden:
|
||||||
|
title: برنامه پنهان شد
|
||||||
|
body: Clash Verge در پسزمینه در حال اجراست.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||||
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
|
||||||
core:
|
|
||||||
alreadyRunning: 'هسته شروع نمیشود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقیمانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راهاندازی مجدد کنید.'
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
currentExeError: 'مکان برنامه مشخص نشد: {error}'
|
||||||
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: سراسری
|
global: سراسری
|
||||||
profiles: پروفایلها
|
profiles: پروفایلها
|
||||||
proxies: پروکسیها
|
proxies: پروکسیها
|
||||||
testLatency: آزمایش تأخیر
|
|
||||||
testingLatency: در حال آزمایش تأخیر…
|
|
||||||
systemProxy: پروکسی سیستم
|
systemProxy: پروکسی سیستم
|
||||||
tunMode: حالت TUN
|
tunMode: حالت TUN
|
||||||
closeAllConnections: بستن همه اتصالها
|
closeAllConnections: بستن همه اتصالها
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: پروکسی سیستم
|
systemProxy: پروکسی سیستم
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: پروفایل
|
profile: پروفایل
|
||||||
|
widget:
|
||||||
|
unavailable: Clash Verge را باز کنید
|
||||||
|
failed: بهروزرسانی ناموفق بود؛ Clash Verge را بررسی کنید
|
||||||
|
'on': روشن
|
||||||
|
'off': خاموش
|
||||||
|
unknown: نامشخص
|
||||||
|
name: مسیریابی Clash Verge
|
||||||
|
description: کنترل حالت مسیریابی، TUN و پراکسی سیستم.
|
||||||
|
update: بهروزرسانی Clash Verge
|
||||||
|
setting: تنظیم
|
||||||
|
value: مقدار
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Akan Keluar
|
title: Akan Keluar
|
||||||
body: Clash Verge akan keluar.
|
body: Clash Verge akan keluar.
|
||||||
|
appHidden:
|
||||||
|
title: Aplikasi Disembunyikan
|
||||||
|
body: Clash Verge berjalan di latar belakang.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
|
||||||
adminUninstallPrompt: Menghapus instalasi 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:
|
launchGuard:
|
||||||
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
|
||||||
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Global
|
global: Global
|
||||||
profiles: Profil
|
profiles: Profil
|
||||||
proxies: Proksi
|
proxies: Proksi
|
||||||
testLatency: Uji Latensi
|
|
||||||
testingLatency: Menguji latensi…
|
|
||||||
systemProxy: Proksi Sistem
|
systemProxy: Proksi Sistem
|
||||||
tunMode: Mode TUN
|
tunMode: Mode TUN
|
||||||
closeAllConnections: Tutup Semua Koneksi
|
closeAllConnections: Tutup Semua Koneksi
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Proksi Sistem
|
systemProxy: Proksi Sistem
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
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,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 終了間近
|
title: 終了間近
|
||||||
body: Clash Verge はまもなく終了します。
|
body: Clash Verge はまもなく終了します。
|
||||||
|
appHidden:
|
||||||
|
title: アプリが非表示
|
||||||
|
body: Clash Verge はバックグラウンドで実行中です。
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
|
||||||
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
|
||||||
core:
|
|
||||||
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
currentExeError: アプリケーションの場所を確認できませんでした:{error}
|
||||||
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: グローバル
|
global: グローバル
|
||||||
profiles: プロファイル
|
profiles: プロファイル
|
||||||
proxies: プロキシ
|
proxies: プロキシ
|
||||||
testLatency: 遅延テスト
|
|
||||||
testingLatency: 遅延をテスト中…
|
|
||||||
systemProxy: システムプロキシ
|
systemProxy: システムプロキシ
|
||||||
tunMode: TUN モード
|
tunMode: TUN モード
|
||||||
closeAllConnections: すべての接続を閉じる
|
closeAllConnections: すべての接続を閉じる
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: システムプロキシ
|
systemProxy: システムプロキシ
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: プロファイル
|
profile: プロファイル
|
||||||
|
widget:
|
||||||
|
unavailable: Clash Verge を開いてください
|
||||||
|
failed: 更新失敗。Clash Verge を確認してください
|
||||||
|
'on': オン
|
||||||
|
'off': オフ
|
||||||
|
unknown: 不明
|
||||||
|
name: Clash Verge ルーティング
|
||||||
|
description: ルーティングモード、TUN、システムプロキシの操作。
|
||||||
|
update: Clash Verge を更新
|
||||||
|
setting: 設定
|
||||||
|
value: 値
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 곧 종료
|
title: 곧 종료
|
||||||
body: Clash Verge가 곧 종료됩니다.
|
body: Clash Verge가 곧 종료됩니다.
|
||||||
|
appHidden:
|
||||||
|
title: 앱이 숨겨짐
|
||||||
|
body: Clash Verge가 백그라운드에서 실행 중입니다.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
|
||||||
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
|
||||||
core:
|
|
||||||
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
|
||||||
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: 글로벌
|
global: 글로벌
|
||||||
profiles: 프로필
|
profiles: 프로필
|
||||||
proxies: 프록시
|
proxies: 프록시
|
||||||
testLatency: 지연 테스트
|
|
||||||
testingLatency: 지연 테스트 중…
|
|
||||||
systemProxy: 시스템 프록시
|
systemProxy: 시스템 프록시
|
||||||
tunMode: TUN 모드
|
tunMode: TUN 모드
|
||||||
closeAllConnections: 모든 연결 닫기
|
closeAllConnections: 모든 연결 닫기
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: 시스템 프록시
|
systemProxy: 시스템 프록시
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: 프로필
|
profile: 프로필
|
||||||
|
widget:
|
||||||
|
unavailable: Clash Verge를 여세요
|
||||||
|
failed: 업데이트 실패. Clash Verge를 확인하세요
|
||||||
|
'on': 켜짐
|
||||||
|
'off': 꺼짐
|
||||||
|
unknown: 알 수 없음
|
||||||
|
name: Clash Verge 라우팅
|
||||||
|
description: 라우팅 모드, TUN 및 시스템 프록시 제어.
|
||||||
|
update: Clash Verge 업데이트
|
||||||
|
setting: 설정
|
||||||
|
value: 값
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
_version: 1
|
|
||||||
notifications:
|
|
||||||
dashboardToggled:
|
|
||||||
title: Painel
|
|
||||||
body: A visibilidade do painel foi atualizada.
|
|
||||||
clashModeChanged:
|
|
||||||
title: Troca de modo
|
|
||||||
body: Alterado para {mode}.
|
|
||||||
systemProxyToggled:
|
|
||||||
title: Proxy do sistema
|
|
||||||
'on': O proxy do sistema foi ativado.
|
|
||||||
'off': O proxy do sistema foi desativado.
|
|
||||||
systemProxyFailed:
|
|
||||||
title: Proxy do sistema
|
|
||||||
body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo.
|
|
||||||
systemProxyGuardStopped:
|
|
||||||
title: Proteção do proxy do sistema
|
|
||||||
body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo.
|
|
||||||
tunModeToggled:
|
|
||||||
title: Modo TUN
|
|
||||||
'on': O modo TUN foi ativado.
|
|
||||||
'off': O modo TUN foi desativado.
|
|
||||||
lightweightModeEntered:
|
|
||||||
title: Modo leve
|
|
||||||
body: Modo leve ativado.
|
|
||||||
profilesReactivated:
|
|
||||||
title: Perfis
|
|
||||||
body: Perfil reativado.
|
|
||||||
appQuit:
|
|
||||||
title: Prestes a sair
|
|
||||||
body: O Clash Verge está prestes a sair.
|
|
||||||
updateReady:
|
|
||||||
title: Atualização do Clash Verge
|
|
||||||
body: Uma nova versão (v{version}) foi baixada e está pronta para instalar.
|
|
||||||
installNow: Instalar agora
|
|
||||||
later: Depois
|
|
||||||
service:
|
|
||||||
adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador.
|
|
||||||
adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador.
|
|
||||||
core:
|
|
||||||
alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.'
|
|
||||||
launchGuard:
|
|
||||||
currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}'
|
|
||||||
homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}'
|
|
||||||
translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente.
|
|
||||||
rejected: |-
|
|
||||||
Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos.
|
|
||||||
|
|
||||||
{reason}
|
|
||||||
notInBundle: O executável não está dentro de um pacote de aplicativo.
|
|
||||||
canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}'
|
|
||||||
bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado.
|
|
||||||
replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos?
|
|
||||||
movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora?
|
|
||||||
createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}'
|
|
||||||
moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente.
|
|
||||||
replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}'
|
|
||||||
relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos.
|
|
||||||
cancel: Cancelar
|
|
||||||
move: Mover
|
|
||||||
ok: OK
|
|
||||||
tray:
|
|
||||||
dashboard: Painel
|
|
||||||
ruleMode: Modo regra
|
|
||||||
globalMode: Modo global
|
|
||||||
directMode: Modo direto
|
|
||||||
outboundModes: Modos de saída
|
|
||||||
rule: Regra
|
|
||||||
direct: Direto
|
|
||||||
global: Global
|
|
||||||
profiles: Perfis
|
|
||||||
proxies: Proxies
|
|
||||||
systemProxy: Proxy do sistema
|
|
||||||
tunMode: Modo TUN
|
|
||||||
closeAllConnections: Fechar todas as conexões
|
|
||||||
lightweightMode: Modo leve
|
|
||||||
copyEnv: Copiar variáveis de ambiente
|
|
||||||
confDir: Diretório de configuração
|
|
||||||
coreDir: Diretório do núcleo
|
|
||||||
logsDir: Diretório de logs
|
|
||||||
openDir: Abrir diretório
|
|
||||||
appLog: Log do aplicativo
|
|
||||||
coreLog: Log do núcleo
|
|
||||||
restartClash: Reiniciar o núcleo do Clash
|
|
||||||
restartApp: Reiniciar o aplicativo
|
|
||||||
vergeVersion: Versão do Verge
|
|
||||||
more: Mais
|
|
||||||
exit: Sair
|
|
||||||
tooltip:
|
|
||||||
systemProxy: Proxy do sistema
|
|
||||||
tun: TUN
|
|
||||||
profile: Perfil
|
|
||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Скорый выход
|
title: Скорый выход
|
||||||
body: Clash Verge скоро завершит работу.
|
body: Clash Verge скоро завершит работу.
|
||||||
|
appHidden:
|
||||||
|
title: Приложение скрыто
|
||||||
|
body: Clash Verge работает в фоновом режиме.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Обновление Clash Verge
|
title: Обновление Clash Verge
|
||||||
body: Новая версия (v{version}) скачана и готова к установке.
|
body: Новая версия (v{version}) скачана и готова к установке.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
|
||||||
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
|
||||||
core:
|
|
||||||
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
currentExeError: 'Не удалось определить расположение приложения: {error}'
|
||||||
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Глобальный
|
global: Глобальный
|
||||||
profiles: Профили
|
profiles: Профили
|
||||||
proxies: Прокси
|
proxies: Прокси
|
||||||
testLatency: Проверить задержку
|
|
||||||
testingLatency: Проверка задержки…
|
|
||||||
systemProxy: Системный прокси
|
systemProxy: Системный прокси
|
||||||
tunMode: Режим TUN
|
tunMode: Режим TUN
|
||||||
closeAllConnections: Закрыть все соединения
|
closeAllConnections: Закрыть все соединения
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Системный прокси
|
systemProxy: Системный прокси
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Профиль
|
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.'
|
body: '{mode} moduna geçildi.'
|
||||||
systemProxyToggled:
|
systemProxyToggled:
|
||||||
title: Sistem Vekil'i
|
title: Sistem Vekil'i
|
||||||
'on': Sistem vekili etkinleştirildi.
|
'on': System proxy has been enabled.
|
||||||
'off': Sistem vekili devre dışı bırakıldı.
|
'off': System proxy has been disabled.
|
||||||
systemProxyFailed:
|
systemProxyFailed:
|
||||||
title: Sistem Vekili
|
title: Sistem Vekili
|
||||||
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
|
||||||
@@ -18,8 +18,8 @@ notifications:
|
|||||||
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
|
||||||
tunModeToggled:
|
tunModeToggled:
|
||||||
title: TUN Modu
|
title: TUN Modu
|
||||||
'on': TUN modu etkinleştirildi.
|
'on': TUN mode has been enabled.
|
||||||
'off': TUN modu devre dışı bırakıldı.
|
'off': TUN mode has been disabled.
|
||||||
lightweightModeEntered:
|
lightweightModeEntered:
|
||||||
title: Hafif Mod
|
title: Hafif Mod
|
||||||
body: Hafif moda geçildi.
|
body: Hafif moda geçildi.
|
||||||
@@ -29,16 +29,17 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Çıkış Yapılmak Üzere
|
title: Çıkış Yapılmak Üzere
|
||||||
body: Clash Verge kapanmak üzere.
|
body: Clash Verge kapanmak üzere.
|
||||||
|
appHidden:
|
||||||
|
title: Uygulama Gizlendi
|
||||||
|
body: Clash Verge arka planda çalışıyor.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Güncellemesi
|
title: Clash Verge Update
|
||||||
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
installNow: Şimdi Kur
|
installNow: Install Now
|
||||||
later: Daha Sonra
|
later: Later
|
||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
|
||||||
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
|
||||||
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:
|
launchGuard:
|
||||||
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
|
||||||
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Küresel
|
global: Küresel
|
||||||
profiles: Profiller
|
profiles: Profiller
|
||||||
proxies: Vekil'ler
|
proxies: Vekil'ler
|
||||||
testLatency: Gecikmeyi Test Et
|
|
||||||
testingLatency: Gecikme test ediliyor…
|
|
||||||
systemProxy: Sistem Vekil'i
|
systemProxy: Sistem Vekil'i
|
||||||
tunMode: TUN Modu
|
tunMode: TUN Modu
|
||||||
closeAllConnections: Tüm Bağlantıları Kapat
|
closeAllConnections: Tüm Bağlantıları Kapat
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Sistem Vekil'i
|
systemProxy: Sistem Vekil'i
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Profil
|
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,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: Чыгар алдыннан
|
title: Чыгар алдыннан
|
||||||
body: Clash Verge чыгарга җыена.
|
body: Clash Verge чыгарга җыена.
|
||||||
|
appHidden:
|
||||||
|
title: Кушымта яшерелде
|
||||||
|
body: Clash Verge фон режимында эшли.
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge Update
|
title: Clash Verge Update
|
||||||
body: A new version (v{version}) has been downloaded and is ready to install.
|
body: A new version (v{version}) has been downloaded and is ready to install.
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
|
||||||
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
|
||||||
core:
|
|
||||||
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
|
||||||
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: Глобаль
|
global: Глобаль
|
||||||
profiles: Профильләр
|
profiles: Профильләр
|
||||||
proxies: Проксилар
|
proxies: Проксилар
|
||||||
testLatency: Кичектерүне тикшерү
|
|
||||||
testingLatency: Кичектерү тикшерелә…
|
|
||||||
systemProxy: Системалы прокси
|
systemProxy: Системалы прокси
|
||||||
tunMode: TUN режимы
|
tunMode: TUN режимы
|
||||||
closeAllConnections: Барлык тоташуларны ябу
|
closeAllConnections: Барлык тоташуларны ябу
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: Системалы прокси
|
systemProxy: Системалы прокси
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: Профиль
|
profile: Профиль
|
||||||
|
widget:
|
||||||
|
unavailable: Clash Verge ачыгыз
|
||||||
|
failed: Яңарту уңышсыз; Clash Verge тикшерегез
|
||||||
|
'on': Кабызылган
|
||||||
|
'off': Сүндерелгән
|
||||||
|
unknown: Билгесез
|
||||||
|
name: Clash Verge маршрутлау
|
||||||
|
description: Маршрутлау режимы, TUN һәм система проксие белән идарә итү.
|
||||||
|
update: Clash Verge яңарту
|
||||||
|
setting: Көйләү
|
||||||
|
value: Кыйммәт
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 即将退出
|
title: 即将退出
|
||||||
body: Clash Verge 即将退出。
|
body: Clash Verge 即将退出。
|
||||||
|
appHidden:
|
||||||
|
title: 应用已隐藏
|
||||||
|
body: Clash Verge 正在后台运行。
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge 更新
|
title: Clash Verge 更新
|
||||||
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
body: 新版本 (v{version}) 已下载完成,是否立即安装?
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
|
||||||
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
|
||||||
core:
|
|
||||||
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 无法确定应用位置:{error}
|
currentExeError: 无法确定应用位置:{error}
|
||||||
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: 全局
|
global: 全局
|
||||||
profiles: 订阅
|
profiles: 订阅
|
||||||
proxies: 代理
|
proxies: 代理
|
||||||
testLatency: 测试延迟
|
|
||||||
testingLatency: 正在测试延迟…
|
|
||||||
systemProxy: 系统代理
|
systemProxy: 系统代理
|
||||||
tunMode: TUN 模式
|
tunMode: TUN 模式
|
||||||
closeAllConnections: 关闭所有连接
|
closeAllConnections: 关闭所有连接
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: 系统代理
|
systemProxy: 系统代理
|
||||||
tun: TUN
|
tun: TUN
|
||||||
profile: 订阅
|
profile: 订阅
|
||||||
|
widget:
|
||||||
|
unavailable: 请打开 Clash Verge
|
||||||
|
failed: 更新失败,请检查 Clash Verge
|
||||||
|
'on': 开启
|
||||||
|
'off': 关闭
|
||||||
|
unknown: 未知
|
||||||
|
name: Clash Verge 路由
|
||||||
|
description: 路由模式、TUN 和系统代理控制。
|
||||||
|
update: 更新 Clash Verge
|
||||||
|
setting: 设置
|
||||||
|
value: 值
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ notifications:
|
|||||||
appQuit:
|
appQuit:
|
||||||
title: 即將退出
|
title: 即將退出
|
||||||
body: Clash Verge 即將退出。
|
body: Clash Verge 即將退出。
|
||||||
|
appHidden:
|
||||||
|
title: 應用已隱藏
|
||||||
|
body: Clash Verge 正在背景執行。
|
||||||
updateReady:
|
updateReady:
|
||||||
title: Clash Verge 更新
|
title: Clash Verge 更新
|
||||||
body: 新版本(v{version})已下載完成,是否立即安裝?
|
body: 新版本(v{version})已下載完成,是否立即安裝?
|
||||||
@@ -37,8 +40,6 @@ notifications:
|
|||||||
service:
|
service:
|
||||||
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
|
||||||
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
|
||||||
core:
|
|
||||||
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
|
|
||||||
launchGuard:
|
launchGuard:
|
||||||
currentExeError: 無法判斷應用程式位置:{error}
|
currentExeError: 無法判斷應用程式位置:{error}
|
||||||
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
|
||||||
@@ -70,8 +71,6 @@ tray:
|
|||||||
global: 全域
|
global: 全域
|
||||||
profiles: 訂閱
|
profiles: 訂閱
|
||||||
proxies: 代理
|
proxies: 代理
|
||||||
testLatency: 測試延遲
|
|
||||||
testingLatency: 正在測試延遲…
|
|
||||||
systemProxy: 系統代理
|
systemProxy: 系統代理
|
||||||
tunMode: 虛擬網路介面卡模式
|
tunMode: 虛擬網路介面卡模式
|
||||||
closeAllConnections: 關閉所有連線
|
closeAllConnections: 關閉所有連線
|
||||||
@@ -92,3 +91,14 @@ tray:
|
|||||||
systemProxy: 系統代理
|
systemProxy: 系統代理
|
||||||
tun: 虛擬網路介面卡
|
tun: 虛擬網路介面卡
|
||||||
profile: 訂閱
|
profile: 訂閱
|
||||||
|
widget:
|
||||||
|
unavailable: 請開啟 Clash Verge
|
||||||
|
failed: 更新失敗,請檢查 Clash Verge
|
||||||
|
'on': 開啟
|
||||||
|
'off': 關閉
|
||||||
|
unknown: 未知
|
||||||
|
name: Clash Verge 路由
|
||||||
|
description: 路由模式、TUN 和系統代理控制。
|
||||||
|
update: 更新 Clash Verge
|
||||||
|
setting: 設定
|
||||||
|
value: 值
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Xcode per-user state and local build output
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// !$*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
@@ -0,0 +1,77 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
sysinfo = { version = "0.39.6", features = ["network", "system"] }
|
||||||
|
|
||||||
[target.'cfg(not(windows))'.dependencies]
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.189"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
deelevate = { workspace = true }
|
deelevate = { workspace = true }
|
||||||
|
|||||||
@@ -1,217 +1,3 @@
|
|||||||
## v2.5.7
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
|
|
||||||
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
|
|
||||||
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
|
|
||||||
|
|
||||||
**🍎 macOS**
|
|
||||||
|
|
||||||
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
|
|
||||||
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
|
|
||||||
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
|
|
||||||
|
|
||||||
**🍎/🐧 macOS/Linux**
|
|
||||||
|
|
||||||
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
## v2.5.6
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
|
||||||
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
|
||||||
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
|
||||||
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
|
|
||||||
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
|
||||||
|
|
||||||
**🍎 macOS**
|
|
||||||
|
|
||||||
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
|
|
||||||
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
|
|
||||||
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
|
|
||||||
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
|
|
||||||
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v2.5.5
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
|
|
||||||
- 优化文件错误日志,显示具体文件路径和失败原因
|
|
||||||
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
|
|
||||||
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
|
|
||||||
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
|
|
||||||
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
|
|
||||||
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
|
|
||||||
- 修复内核未运行时,首页仍显示服务模式的问题
|
|
||||||
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
|
||||||
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
|
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
|
||||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
|
||||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
|
||||||
- 优化预构建资源,补充 ASN 数据库文件
|
|
||||||
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v2.5.4
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
|
||||||
|
|
||||||
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
|
|
||||||
- 修复系统代理切换失败后,开关状态显示错误的问题
|
|
||||||
- 修复代理守卫失败后反复重试且没有提示的问题
|
|
||||||
- 修复未开启系统代理时,启动仍提示代理错误的问题
|
|
||||||
- 修复恢复备份后未按备份设置关闭系统代理的问题
|
|
||||||
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
|
|
||||||
- 修复部分节点显示「Unknown」或无法测试延迟的问题
|
|
||||||
- 修复 `PASS-RULE` 节点被错误隐藏的问题
|
|
||||||
- 修复重启内核后未恢复上次所选节点的问题
|
|
||||||
- 修复部分代理组和首页图标无法显示、页面报错的问题
|
|
||||||
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
|
|
||||||
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
|
|
||||||
- 修复切换节点时应用可能卡住的问题
|
|
||||||
- 修复订阅文件被误删、删除或排序后列表异常的问题
|
|
||||||
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
|
|
||||||
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
|
|
||||||
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
|
|
||||||
- 修复关闭自动检查更新后,手动检查更新无效的问题
|
|
||||||
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
|
|
||||||
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
|
|
||||||
- 修复 Windows 系统代理设置偶发失败的问题
|
|
||||||
- 修复 Windows 关机被应用阻止的问题
|
|
||||||
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
|
|
||||||
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
|
|
||||||
|
|
||||||
**🍎 macOS**
|
|
||||||
|
|
||||||
- 修复 macOS 内核更新失败后无法启动的问题
|
|
||||||
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
|
|
||||||
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
|
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 修复 Linux 安装或卸载服务时授权失败的问题
|
|
||||||
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
|
|
||||||
|
|
||||||
**🍎/🐧 macOS/Linux**
|
|
||||||
|
|
||||||
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
|
|
||||||
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
|
||||||
|
|
||||||
- 新增混合代理端口自动避让,被占用时临时使用其他端口
|
|
||||||
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
|
|
||||||
- 新增服务模式多用户隔离,支持旧版服务升级迁移
|
|
||||||
- 新增手动取消节点固定功能
|
|
||||||
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
|
|
||||||
- 新增前置规则与后置规则互相移动功能
|
|
||||||
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
|
|
||||||
- 新增代理页排查提示,方便检查订阅和内核状态
|
|
||||||
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
|
|
||||||
- 新增启动失败提示,显示原因并保存启动日志
|
|
||||||
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
|
|
||||||
|
|
||||||
**🍎 macOS**
|
|
||||||
|
|
||||||
- 新增 macOS Liquid Glass 风格图标
|
|
||||||
- 新增 macOS 多用户启动保护,避免多个实例冲突
|
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 新增 Linux 系统主题跟随,自动切换深浅色外观
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
|
||||||
|
|
||||||
- 优化首页节点菜单关闭时的刷新性能
|
|
||||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
|
||||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
|
||||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
|
||||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
|
||||||
- 优化代理列表刷新、排序和页面切换速度
|
|
||||||
- 优化首页节点操作,显示节点固定状态
|
|
||||||
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
|
|
||||||
- 优化更新说明排版,支持表格和提示框
|
|
||||||
- 优化搜索框聚焦、界面字体和拖动排序动画
|
|
||||||
- 优化后台连接和托盘刷新,降低资源占用
|
|
||||||
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
|
|
||||||
- 优化日志记录,减少重复信息
|
|
||||||
- 移除便携模式,统一使用系统应用数据目录
|
|
||||||
- 优化繁体中文、俄语翻译及语言名称显示
|
|
||||||
|
|
||||||
**🖥️ Windows**
|
|
||||||
|
|
||||||
- 优化 Windows 自动更新,跳过安装语言选择
|
|
||||||
|
|
||||||
**🐧 Linux**
|
|
||||||
|
|
||||||
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
## v2.5.2
|
## v2.5.2
|
||||||
|
|
||||||
### 🐞 修复问题
|
### 🐞 修复问题
|
||||||
|
|||||||
+6
-7
@@ -11,14 +11,13 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Languages:
|
Languages:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
@@ -52,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
|||||||
|
|
||||||
## Promotion
|
## Promotion
|
||||||
|
|
||||||
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
|
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
|
||||||
|
|
||||||
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
#### **Core Advantages:**
|
#### **Core Advantages:**
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
|
|||||||
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
|
||||||
- 🎬 Unlocks **streaming platforms and mainstream AI services**
|
- 🎬 Unlocks **streaming platforms and mainstream AI services**
|
||||||
|
|
||||||
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -11,14 +11,13 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Idiomas:
|
Idiomas:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Vista previa
|
## Vista previa
|
||||||
@@ -52,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
|
|||||||
|
|
||||||
## Promociones
|
## Promociones
|
||||||
|
|
||||||
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
|
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
|
||||||
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
|
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
|
||||||
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
|
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
|
||||||
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
|
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
|
||||||
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
|
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
|
||||||
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
|
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
|
||||||
- Desbloquea servicios de streaming y acceso a ChatGPT
|
- Desbloquea servicios de streaming y acceso a ChatGPT
|
||||||
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -11,14 +11,13 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
زبانها:
|
زبانها:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## پیشنمایش
|
## پیشنمایش
|
||||||
@@ -51,17 +50,17 @@
|
|||||||
|
|
||||||
## تبلیغات
|
## تبلیغات
|
||||||
|
|
||||||
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — شتابدهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دورههای آزمایشی رایگان، طرحهای تخفیفدار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
|
||||||
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
|
||||||
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
- بسته تخفیفدار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
|
||||||
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره میشود
|
||||||
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینتهای قدیمی)، تأخیر فوقالعاده کم، پخش روان 4K
|
||||||
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
- اولین ارائهدهنده جهانی با **پروتکل QUIC**، اکنون با پروتکلهای سریعتر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
|
||||||
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
- پشتیبانی از سرویسهای استریم و دسترسی به ChatGPT
|
||||||
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -11,14 +11,13 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
言語:
|
言語:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## プレビュー
|
## プレビュー
|
||||||
@@ -52,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
|
|||||||
|
|
||||||
## プロモーション
|
## プロモーション
|
||||||
|
|
||||||
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
|
||||||
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
|
||||||
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
|
||||||
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
|
||||||
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
|
||||||
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
|
||||||
- ストリーミングおよび ChatGPT の利用にも対応
|
- ストリーミングおよび ChatGPT の利用にも対応
|
||||||
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+6
-7
@@ -11,14 +11,13 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
언어:
|
언어:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 미리보기
|
## 미리보기
|
||||||
@@ -52,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
|
|||||||
|
|
||||||
## 프로모션
|
## 프로모션
|
||||||
|
|
||||||
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
|
||||||
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
|
||||||
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
|
||||||
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
|
||||||
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
|
||||||
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
|
||||||
- 스트리밍 및 ChatGPT 접근 지원
|
- 스트리밍 및 ChatGPT 접근 지원
|
||||||
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<h1 align="center">
|
|
||||||
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
|
|
||||||
<br>
|
|
||||||
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
|
|
||||||
<br>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<h3 align="center">
|
|
||||||
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
Idiomas:
|
|
||||||
<a href="./README_en.md">English</a> ·
|
|
||||||
<a href="./README_es.md">Español</a> ·
|
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
|
||||||
<a href="./README_pt.md">Português</a> ·
|
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Pré-visualização
|
|
||||||
|
|
||||||
| Escuro | Claro |
|
|
||||||
| -------------------------------------- | -------------------------------------- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
## Instalação
|
|
||||||
|
|
||||||
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
|
|
||||||
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
|
|
||||||
|
|
||||||
#### Como escolher o canal de lançamento
|
|
||||||
|
|
||||||
| Canal | Descrição | Link |
|
|
||||||
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
|
|
||||||
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
|
|
||||||
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
|
|
||||||
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
|
|
||||||
|
|
||||||
#### Guias de instalação e perguntas frequentes
|
|
||||||
|
|
||||||
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
|
|
||||||
|
|
||||||
### Canal do Telegram
|
|
||||||
|
|
||||||
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Promoções
|
|
||||||
|
|
||||||
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
|
||||||
|
|
||||||
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
|
|
||||||
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
|
||||||
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
|
|
||||||
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
|
|
||||||
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
|
|
||||||
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
|
|
||||||
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
|
|
||||||
- Desbloqueia serviços de streaming e acesso ao ChatGPT
|
|
||||||
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
|
||||||
|
|
||||||
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
|
|
||||||
|
|
||||||
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
|
|
||||||
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
|
|
||||||
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
|
|
||||||
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
|
|
||||||
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
|
|
||||||
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Funcionalidades
|
|
||||||
|
|
||||||
- Baseado em Rust de alto desempenho e no framework Tauri 2
|
|
||||||
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
|
|
||||||
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
|
|
||||||
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
|
|
||||||
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
|
|
||||||
- Editores visuais para nós e regras
|
|
||||||
- Backup e sincronização via WebDAV
|
|
||||||
|
|
||||||
### Perguntas frequentes
|
|
||||||
|
|
||||||
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
|
|
||||||
|
|
||||||
### Doações
|
|
||||||
|
|
||||||
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
|
|
||||||
|
|
||||||
## Desenvolvimento
|
|
||||||
|
|
||||||
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
|
|
||||||
|
|
||||||
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
pnpm i
|
|
||||||
pnpm run prebuild
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contribuições
|
|
||||||
|
|
||||||
Issues e pull requests são bem-vindos.
|
|
||||||
|
|
||||||
## Agradecimentos
|
|
||||||
|
|
||||||
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
|
|
||||||
|
|
||||||
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
|
|
||||||
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
|
|
||||||
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
|
|
||||||
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
|
|
||||||
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
|
|
||||||
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
|
|
||||||
|
|
||||||
## Licença
|
|
||||||
|
|
||||||
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
|
|
||||||
+6
-7
@@ -11,14 +11,13 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Языки:
|
Языки:
|
||||||
|
<a href="../README.md">简体中文</a> ·
|
||||||
<a href="./README_en.md">English</a> ·
|
<a href="./README_en.md">English</a> ·
|
||||||
<a href="./README_es.md">Español</a> ·
|
<a href="./README_es.md">Español</a> ·
|
||||||
<a href="./README_fa.md">فارسی</a> ·
|
<a href="./README_ru.md">Русский</a> ·
|
||||||
<a href="./README_ja.md">日本語</a> ·
|
<a href="./README_ja.md">日本語</a> ·
|
||||||
<a href="./README_ko.md">한국어</a> ·
|
<a href="./README_ko.md">한국어</a> ·
|
||||||
<a href="./README_pt.md">Português</a> ·
|
<a href="./README_fa.md">فارسی</a>
|
||||||
<a href="./README_ru.md">Русский</a> ·
|
|
||||||
<a href="../README.md">简体中文</a>
|
|
||||||
</p>
|
</p>
|
||||||
## Предпросмотр
|
## Предпросмотр
|
||||||
|
|
||||||
@@ -48,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
|
|||||||
|
|
||||||
## Продвижение
|
## Продвижение
|
||||||
|
|
||||||
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
|
||||||
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
|
||||||
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
|
||||||
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
- Команда за рубежом, без риска побега, до 50% кэшбэка
|
||||||
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
|
||||||
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
|
||||||
- Разблокировка потоковые сервисы и ChatGPT
|
- Разблокировка потоковые сервисы и ChatGPT
|
||||||
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
|
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
|
||||||
|
|
||||||
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -43,6 +43,7 @@ export default defineConfig([
|
|||||||
'eslint.config.ts',
|
'eslint.config.ts',
|
||||||
`vite.config.mts`,
|
`vite.config.mts`,
|
||||||
'scripts/*.mjs',
|
'scripts/*.mjs',
|
||||||
|
'crates/clash-verge-widget/build/*.mjs',
|
||||||
'tests/*.ts',
|
'tests/*.ts',
|
||||||
'src/polyfills/*.js',
|
'src/polyfills/*.js',
|
||||||
'tests/*.mjs',
|
'tests/*.mjs',
|
||||||
@@ -143,7 +144,7 @@ export default defineConfig([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['scripts/*.mjs'],
|
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
"ignoreBinaries": ["rustc", "cc"],
|
"ignoreBinaries": ["rustc", "cc"],
|
||||||
"ignoreDependencies": ["lint-staged"],
|
"ignoreDependencies": ["lint-staged"],
|
||||||
"ignoreIssues": {
|
"ignoreIssues": {
|
||||||
"src/types/generated/**": ["exports", "types"],
|
"src/types/generated/**": ["exports", "types"]
|
||||||
"src/services/contract.ts": ["exports", "types"]
|
|
||||||
},
|
},
|
||||||
"cycles": {
|
"cycles": {
|
||||||
"dynamicImports": true
|
"dynamicImports": true
|
||||||
|
|||||||
+60
-62
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clash-verge",
|
"name": "clash-verge",
|
||||||
"version": "2.5.8",
|
"version": "2.5.4",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky || true",
|
"prepare": "husky || true",
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"dev:diff": "node scripts/dev.mjs dev",
|
"dev:diff": "node scripts/dev.mjs dev",
|
||||||
"dev:trace": "node scripts/dev.mjs trace",
|
"dev:trace": "node scripts/dev.mjs trace",
|
||||||
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
|
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
|
||||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
|
"build": "node scripts/build.mjs",
|
||||||
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
|
"build:fast": "node scripts/build.mjs -- --profile fast-release",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"web:dev": "vite",
|
"web:dev": "vite",
|
||||||
"web:build": "tsc --noEmit && vite build",
|
"web:build": "tsc --noEmit && vite build",
|
||||||
@@ -30,8 +30,6 @@
|
|||||||
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
|
||||||
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
|
||||||
"i18n:types": "node scripts/generate-i18n-keys.mjs",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -51,90 +49,90 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.4.0",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.13.25",
|
||||||
"@tauri-apps/api": "2.12.1",
|
"@tauri-apps/api": "2.11.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "2.8.0",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-fs": "2.6.0",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-http": "2.8.0",
|
"@tauri-apps/plugin-http": "~2.5.9",
|
||||||
"@tauri-apps/plugin-opener": "2.7.0",
|
"@tauri-apps/plugin-opener": "2.5.4",
|
||||||
"@tauri-apps/plugin-updater": "2.13.1",
|
"@tauri-apps/plugin-updater": "2.10.1",
|
||||||
"ahooks": "^3.10.0",
|
"ahooks": "^3.9.7",
|
||||||
"cidr-block": "^2.3.0",
|
"cidr-block": "^2.3.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dayjs": "1.11.23",
|
"dayjs": "1.11.21",
|
||||||
"foxact": "^0.3.12",
|
"foxact": "^0.3.4",
|
||||||
"foxts": "^5.9.1",
|
"foxts": "^5.4.2",
|
||||||
"i18next": "^26.4.2",
|
"i18next": "^26.2.0",
|
||||||
"js-yaml": "^5.4.2",
|
"js-yaml": "^5.0.0",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
"meta-json-schema": "^1.19.31",
|
"meta-json-schema": "^1.19.25",
|
||||||
"monaco-editor": "^0.57.0",
|
"monaco-editor": "^0.56.0",
|
||||||
"monaco-yaml": "^5.5.1",
|
"monaco-yaml": "^5.5.0",
|
||||||
"nanoid": "^6.0.1",
|
"nanoid": "^6.0.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.3.0",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.3.0",
|
"react-dom": "19.2.8",
|
||||||
"react-error-boundary": "6.1.6",
|
"react-error-boundary": "6.1.2",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-i18next": "17.0.15",
|
"react-i18next": "17.0.11",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^8.4.0",
|
"react-router": "^8.0.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"swr": "^2.5.1",
|
"swr": "^2.4.2",
|
||||||
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
"validator": "^13.15.35"
|
"validator": "^13.15.35"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/github": "^9.1.1",
|
"@actions/github": "^9.1.1",
|
||||||
"@biomejs/biome": "^2.5.14",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@eslint-react/eslint-plugin": "^5.20.5",
|
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tauri-apps/cli": "2.12.1",
|
"@tauri-apps/cli": "2.11.4",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^24.13.6",
|
"@types/node": "^24.12.4",
|
||||||
"@types/react": "19.3.0",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.3.0",
|
"@types/react-dom": "19.2.4",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"@vitejs/plugin-legacy": "^8.2.3",
|
"@vitejs/plugin-legacy": "^8.0.2",
|
||||||
"@vitejs/plugin-react": "^6.1.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@wdio/tauri-service": "1.4.0",
|
"@wdio/tauri-service": "1.3.0",
|
||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.16.1",
|
||||||
"cli-color": "^2.0.4",
|
"cli-color": "^2.0.4",
|
||||||
"commander": "^15.0.0",
|
"commander": "^15.0.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-import-resolver-typescript": "^4.4.5",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-es-x": "^10.0.1",
|
"eslint-plugin-es-x": "^10.0.0",
|
||||||
"eslint-plugin-import-x": "^4.17.1",
|
"eslint-plugin-import-x": "^4.16.2",
|
||||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.7",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"eslint-plugin-sukka": "^10.2.0",
|
"eslint-plugin-sukka": "^10.0.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"glob": "^13.0.6",
|
"glob": "^13.0.6",
|
||||||
"globals": "^17.12.0",
|
"globals": "^17.6.0",
|
||||||
"https-proxy-agent": "^9.1.0",
|
"https-proxy-agent": "^9.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"knip": "6.39.0",
|
"knip": "6.32.1",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.0.5",
|
||||||
"sass": "^1.104.1",
|
"sass": "^1.100.0",
|
||||||
"tar": "^7.5.22",
|
"tar": "^7.5.15",
|
||||||
"terser": "^5.51.2",
|
"terser": "^5.48.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.70.0",
|
"typescript-eslint": "^8.59.4",
|
||||||
"vite": "^8.3.0",
|
"vite": "^8.0.14",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.0",
|
||||||
"webdriverio": "10.0.0",
|
"webdriverio": "9.30.0",
|
||||||
"ws": "8.22.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,mjs}": [
|
"*.{ts,tsx,js,mjs}": [
|
||||||
@@ -146,5 +144,5 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@12.10.0"
|
"packageManager": "pnpm@12.4.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1791
-1547
File diff suppressed because it is too large
Load Diff
+1
-17
@@ -56,12 +56,6 @@
|
|||||||
"matchFileNames": [".github/workflows/*.lock.yml"],
|
"matchFileNames": [".github/workflows/*.lock.yml"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
|
|
||||||
"matchFileNames": [".github/workflows/**"],
|
|
||||||
"matchPackageNames": ["/gh-aw/"],
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Skip TypeScript 7.0 series",
|
"description": "Skip TypeScript 7.0 series",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
@@ -84,7 +78,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
|
||||||
"matchPackageNames": ["pnpm"],
|
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
|
||||||
"groupName": "pnpm"
|
"groupName": "pnpm"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,16 +131,6 @@
|
|||||||
],
|
],
|
||||||
"depNameTemplate": "rust-lang/rust",
|
"depNameTemplate": "rust-lang/rust",
|
||||||
"datasourceTemplate": "github-releases"
|
"datasourceTemplate": "github-releases"
|
||||||
},
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Update pnpm in devcontainer.json",
|
|
||||||
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
|
|
||||||
"matchStrings": [
|
|
||||||
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
|
|
||||||
],
|
|
||||||
"depNameTemplate": "pnpm",
|
|
||||||
"datasourceTemplate": "npm"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"postUpdateOptions": ["pnpmDedupe"],
|
"postUpdateOptions": ["pnpmDedupe"],
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.99.0"
|
channel = "1.98.1"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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()
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Architecture grep gates for the frontend contract flow. Select gates via
|
|
||||||
* argv:
|
|
||||||
*
|
|
||||||
* node scripts/check-architecture.mjs listen emit invoke
|
|
||||||
*
|
|
||||||
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
|
|
||||||
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
|
|
||||||
* like `this.emit(` are unrelated class methods) or as importing the name
|
|
||||||
* from '@tauri-apps/api/event'.
|
|
||||||
* - invoke: `invoke(` calls may exist only under src/services/**.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
||||||
const SRC = path.join(ROOT, 'src')
|
|
||||||
|
|
||||||
const tauriEventImport = (name) =>
|
|
||||||
new RegExp(
|
|
||||||
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const GATES = {
|
|
||||||
listen: {
|
|
||||||
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
|
|
||||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
|
||||||
description: 'Tauri listen outside the event bus (src/services/bus)',
|
|
||||||
},
|
|
||||||
emit: {
|
|
||||||
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
|
|
||||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
|
||||||
description: 'Tauri emit outside the event bus (src/services/bus)',
|
|
||||||
},
|
|
||||||
invoke: {
|
|
||||||
patterns: [/\binvoke\(/],
|
|
||||||
allowed: (file) => file.startsWith('src/services/'),
|
|
||||||
description: 'invoke( outside the services layer (src/services)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const walk = (dir) => {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
||||||
return entries.flatMap((entry) => {
|
|
||||||
const full = path.join(dir, entry.name)
|
|
||||||
if (entry.isDirectory()) return walk(full)
|
|
||||||
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = process.argv.slice(2)
|
|
||||||
if (selected.length === 0) {
|
|
||||||
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
|
||||||
process.exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
let violations = 0
|
|
||||||
for (const gate of selected) {
|
|
||||||
const rule = GATES[gate]
|
|
||||||
if (!rule) {
|
|
||||||
console.error(`unknown gate: ${gate}`)
|
|
||||||
process.exit(2)
|
|
||||||
}
|
|
||||||
for (const file of walk(SRC)) {
|
|
||||||
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
|
|
||||||
if (rule.allowed(relative)) continue
|
|
||||||
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
if (rule.patterns.some((pattern) => pattern.test(line))) {
|
|
||||||
violations += 1
|
|
||||||
console.error(
|
|
||||||
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (violations > 0) {
|
|
||||||
console.error(`[arch] ${violations} violation(s) found`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
console.log('[arch] no violations')
|
|
||||||
@@ -49,7 +49,7 @@ const FRONTEND_EXTENSIONS = new Set([
|
|||||||
'.vue',
|
'.vue',
|
||||||
'.json',
|
'.json',
|
||||||
])
|
])
|
||||||
const BACKEND_EXTENSIONS = new Set(['.rs'])
|
const BACKEND_EXTENSIONS = new Set(['.rs', '.swift'])
|
||||||
|
|
||||||
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
||||||
|
|
||||||
|
|||||||
+141
-85
@@ -1,23 +1,36 @@
|
|||||||
import { execFileSync, spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { access } from 'node:fs/promises'
|
import { createHash } from 'node:crypto'
|
||||||
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
import { dirname, join, resolve } from 'node:path'
|
import { dirname, join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
export const developmentServiceDirectoryEnvironment =
|
export const developmentServiceDirectoryEnvironment =
|
||||||
'CLASH_VERGE_DEV_SERVICE_DIR'
|
'CLASH_VERGE_DEV_SERVICE_DIR'
|
||||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const developmentServiceInstallerEnvironment =
|
||||||
|
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repositoryRoot = resolve(scriptDirectory, '..')
|
||||||
const serviceRepository = resolve(
|
const serviceRepository = resolve(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
'..',
|
'..',
|
||||||
'clash-verge-service-ipc',
|
'clash-verge-service-ipc',
|
||||||
)
|
)
|
||||||
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
const serviceManifest = join(serviceRepository, 'Cargo.toml')
|
||||||
export const developmentServiceWatchPaths = [
|
const developmentReceipt = join(
|
||||||
'src',
|
repositoryRoot,
|
||||||
'resources',
|
'target',
|
||||||
'Cargo.toml',
|
'development-service.json',
|
||||||
'Cargo.lock',
|
)
|
||||||
].map((name) => join(serviceRepository, name))
|
|
||||||
|
function executable(name, platform) {
|
||||||
|
return join(
|
||||||
|
serviceRepository,
|
||||||
|
'target',
|
||||||
|
'debug',
|
||||||
|
platform === 'win32' ? `${name}.exe` : name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function run(command, args, options = {}) {
|
function run(command, args, options = {}) {
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
@@ -26,15 +39,10 @@ function run(command, args, options = {}) {
|
|||||||
stdio: options.stdio ?? 'inherit',
|
stdio: options.stdio ?? 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
})
|
})
|
||||||
let stdout = ''
|
|
||||||
child.stdout?.setEncoding('utf8')
|
|
||||||
child.stdout?.on('data', (chunk) => {
|
|
||||||
stdout += chunk
|
|
||||||
})
|
|
||||||
child.once('error', reject)
|
child.once('error', reject)
|
||||||
child.once('close', (code, signal) => {
|
child.once('exit', (code, signal) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolvePromise(stdout)
|
resolvePromise()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
@@ -46,86 +54,134 @@ function run(command, args, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareDevelopmentService() {
|
async function hashFiles(paths) {
|
||||||
await access(serviceManifest)
|
const hash = createHash('sha256')
|
||||||
const output = await run(
|
for (const path of paths) {
|
||||||
'cargo',
|
hash.update(await readFile(path))
|
||||||
[
|
}
|
||||||
'build',
|
return hash.digest('hex')
|
||||||
'--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 artifacts = new Map()
|
async function readInstalledFingerprint() {
|
||||||
for (const line of output.split(/\r?\n/).filter(Boolean)) {
|
try {
|
||||||
const artifact = JSON.parse(line)
|
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
|
||||||
if (
|
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
|
||||||
artifact.reason === 'compiler-artifact' &&
|
} catch {
|
||||||
resolve(artifact.manifest_path) === serviceManifest &&
|
return null
|
||||||
artifact.target.kind.includes('bin') &&
|
|
||||||
artifact.executable
|
|
||||||
) {
|
|
||||||
artifacts.set(artifact.target.name, resolve(artifact.executable))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const executables = [
|
}
|
||||||
'clash-verge-service',
|
|
||||||
'clash-verge-service-install',
|
async function writeInstalledFingerprint(fingerprint) {
|
||||||
'clash-verge-service-uninstall',
|
await mkdir(dirname(developmentReceipt), { recursive: true })
|
||||||
'service-integration-driver',
|
await writeFile(
|
||||||
].map((name) => {
|
developmentReceipt,
|
||||||
const executable = artifacts.get(name)
|
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
|
||||||
if (!executable)
|
'utf8',
|
||||||
throw new Error(`Cargo did not report the ${name} executable`)
|
)
|
||||||
return executable
|
}
|
||||||
})
|
|
||||||
const serviceDirectory = dirname(executables[0])
|
function windowsElevationInvocation(installer, environment = process.env) {
|
||||||
for (const executable of executables) {
|
const script = [
|
||||||
if (dirname(executable) !== serviceDirectory) {
|
"$ErrorActionPreference = 'Stop'",
|
||||||
throw new Error(
|
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
|
||||||
'Development service tools were built in different directories',
|
"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',
|
||||||
await access(executable)
|
].join('; ')
|
||||||
|
return {
|
||||||
|
command: 'powershell.exe',
|
||||||
|
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
||||||
|
env: {
|
||||||
|
...environment,
|
||||||
|
[developmentServiceInstallerEnvironment]: installer,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return serviceDirectory
|
}
|
||||||
|
|
||||||
|
async function elevateInstaller(installer, platform) {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
const invocation = windowsElevationInvocation(installer)
|
||||||
|
return run(invocation.command, invocation.args, { env: invocation.env })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const script =
|
||||||
|
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
|
||||||
|
return run('osascript', ['-e', script, installer, String(process.getgid())])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
||||||
|
return run(installer, [], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run('pkexec', [installer])
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
|
||||||
|
await run('sudo', [installer])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareDevelopmentService({
|
||||||
|
platform = process.platform,
|
||||||
|
} = {}) {
|
||||||
|
await access(serviceManifest)
|
||||||
|
await run('cargo', [
|
||||||
|
'build',
|
||||||
|
'--manifest-path',
|
||||||
|
serviceManifest,
|
||||||
|
'--features',
|
||||||
|
'standalone,client,development-channel',
|
||||||
|
'--bins',
|
||||||
|
])
|
||||||
|
|
||||||
|
const service = executable('clash-verge-service', platform)
|
||||||
|
await access(service)
|
||||||
|
return dirname(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureDevelopmentService({
|
export async function ensureDevelopmentService({
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const serviceDirectory = await prepareDevelopmentService()
|
const serviceDirectory = await prepareDevelopmentService({ platform })
|
||||||
const extension = platform === 'win32' ? '.exe' : ''
|
const service = join(
|
||||||
|
serviceDirectory,
|
||||||
|
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
|
||||||
|
)
|
||||||
const installer = join(
|
const installer = join(
|
||||||
serviceDirectory,
|
serviceDirectory,
|
||||||
`clash-verge-service-install${extension}`,
|
platform === 'win32'
|
||||||
|
? 'clash-verge-service-install.exe'
|
||||||
|
: 'clash-verge-service-install',
|
||||||
)
|
)
|
||||||
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
|
const driver = join(
|
||||||
/^host: (.+)$/m,
|
serviceDirectory,
|
||||||
)?.[1]
|
platform === 'win32'
|
||||||
if (!host) throw new Error('rustc did not report its host target')
|
? 'service-integration-driver.exe'
|
||||||
const args = ['--prepare-install', '--ensure']
|
: 'service-integration-driver',
|
||||||
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
|
)
|
||||||
args.push(
|
const fingerprint = await hashFiles([service, installer])
|
||||||
'--core',
|
const installedFingerprint = await readInstalledFingerprint()
|
||||||
`${name}${extension}`,
|
|
||||||
join(
|
if (installedFingerprint === fingerprint) {
|
||||||
repositoryRoot,
|
try {
|
||||||
'src-tauri',
|
await run(driver, ['probe'], { stdio: 'ignore' })
|
||||||
'sidecar',
|
return serviceDirectory
|
||||||
`${name}-${host}${extension}`,
|
} catch {
|
||||||
),
|
// The expected binary is installed but unavailable; the repair tool owns recovery.
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
await run(installer, args)
|
|
||||||
|
try {
|
||||||
|
await elevateInstaller(installer, platform)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.exitCode !== 75) throw error
|
||||||
|
}
|
||||||
|
await run(driver, ['ready'])
|
||||||
|
await writeInstalledFingerprint(fingerprint)
|
||||||
return serviceDirectory
|
return serviceDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from './dev-control.mjs'
|
} from './dev-control.mjs'
|
||||||
import {
|
import {
|
||||||
developmentServiceDirectoryEnvironment,
|
developmentServiceDirectoryEnvironment,
|
||||||
developmentServiceWatchPaths,
|
|
||||||
ensureDevelopmentService,
|
ensureDevelopmentService,
|
||||||
prepareDevelopmentService,
|
prepareDevelopmentService,
|
||||||
} from './dev-service.mjs'
|
} from './dev-service.mjs'
|
||||||
@@ -32,9 +31,6 @@ export function buildTauriInvocation(
|
|||||||
env.RUSTFLAGS = '--cfg tokio_unstable'
|
env.RUSTFLAGS = '--cfg tokio_unstable'
|
||||||
args.push('tokio-trace')
|
args.push('tokio-trace')
|
||||||
}
|
}
|
||||||
for (const path of developmentServiceWatchPaths) {
|
|
||||||
args.push('--additional-watch-folders', path)
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
command: 'pnpm',
|
command: 'pnpm',
|
||||||
args,
|
args,
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { execFileSync } from 'node:child_process'
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regenerates src/services/contract.ts from the backend wire contract.
|
|
||||||
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
|
|
||||||
* src-tauri, which serializes the same enum variants and event payloads the
|
|
||||||
* app emits at runtime. CI reruns this and fails on a non-empty diff.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
||||||
const OUT = path.join(ROOT, 'src/services/contract.ts')
|
|
||||||
|
|
||||||
const contract = JSON.parse(
|
|
||||||
execFileSync(
|
|
||||||
'cargo',
|
|
||||||
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
|
|
||||||
{
|
|
||||||
cwd: path.join(ROOT, 'src-tauri'),
|
|
||||||
encoding: 'utf8',
|
|
||||||
maxBuffer: 16 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (contract.version !== 1) {
|
|
||||||
throw new Error(`unsupported wire contract version: ${contract.version}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TS type per event, derived from the serialized sample payload's JSON shape. */
|
|
||||||
const payloadType = (name, sample) => {
|
|
||||||
if (sample === null) return 'null'
|
|
||||||
if (typeof sample === 'string') return 'string'
|
|
||||||
if (Array.isArray(sample)) {
|
|
||||||
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
|
|
||||||
throw new Error(`unexpected array payload for ${name}`)
|
|
||||||
}
|
|
||||||
if (typeof sample === 'object') {
|
|
||||||
const keys = Object.keys(sample)
|
|
||||||
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
|
|
||||||
if (name === 'verge://run-state-changed') return 'RunState'
|
|
||||||
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected payload for ${name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsRunState = contract.events.some(
|
|
||||||
(e) => e.name === 'verge://run-state-changed',
|
|
||||||
)
|
|
||||||
if (!needsRunState)
|
|
||||||
throw new Error('run-state-changed event missing from contract')
|
|
||||||
|
|
||||||
const lines = []
|
|
||||||
lines.push(
|
|
||||||
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
|
|
||||||
)
|
|
||||||
lines.push('// Regen: pnpm gen:contract')
|
|
||||||
lines.push('')
|
|
||||||
lines.push("import type { RunState } from './cmds'")
|
|
||||||
lines.push('')
|
|
||||||
lines.push(
|
|
||||||
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
|
|
||||||
)
|
|
||||||
lines.push('export type NoticeStatus =')
|
|
||||||
for (const status of contract.noticeStatuses) {
|
|
||||||
lines.push(` | ${JSON.stringify(status)}`)
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
lines.push('/** Event name → payload type for every backend-emitted event. */')
|
|
||||||
lines.push('export interface VergeEventPayloads {')
|
|
||||||
for (const event of contract.events) {
|
|
||||||
const key = JSON.stringify(event.name)
|
|
||||||
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
|
|
||||||
}
|
|
||||||
lines.push('}')
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
const content = `${lines.join('\n')}\n`
|
|
||||||
fs.writeFileSync(OUT, content)
|
|
||||||
|
|
||||||
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
|
|
||||||
execFileSync(
|
|
||||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
|
||||||
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
|
|
||||||
{ cwd: ROOT, stdio: 'ignore' },
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
|
|
||||||
)
|
|
||||||
+11
-53
@@ -1,9 +1,9 @@
|
|||||||
import { execFileSync, execSync } from 'node:child_process'
|
import { execFileSync, execSync } from 'child_process'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'fs'
|
||||||
import fsp from 'node:fs/promises'
|
import fsp from 'fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'path'
|
||||||
import zlib from 'node:zlib'
|
import zlib from 'zlib'
|
||||||
|
|
||||||
import AdmZip from 'adm-zip'
|
import AdmZip from 'adm-zip'
|
||||||
import { glob } from 'glob'
|
import { glob } from 'glob'
|
||||||
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
|
|||||||
const zip = new AdmZip(tempZip)
|
const zip = new AdmZip(tempZip)
|
||||||
zip
|
zip
|
||||||
.getEntries()
|
.getEntries()
|
||||||
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
|
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
|
||||||
zip.extractAllTo(tempDir, true)
|
zip.extractAllTo(tempDir, true)
|
||||||
if (fs.existsSync(tempDll)) {
|
if (fs.existsSync(tempDll)) {
|
||||||
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
|
||||||
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
|
|||||||
|
|
||||||
function serviceFileInfo(name) {
|
function serviceFileInfo(name) {
|
||||||
const ext = platform === 'win32' ? '.exe' : ''
|
const ext = platform === 'win32' ? '.exe' : ''
|
||||||
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
|
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
|
||||||
return {
|
return {
|
||||||
sourceFile: `${name}${ext}`,
|
sourceFile: `${name}${ext}`,
|
||||||
targetFile: `${name}${suffix}${ext}`,
|
targetFile: `${name}${suffix}${ext}`,
|
||||||
@@ -586,41 +586,6 @@ async function resolveServiceBundle() {
|
|||||||
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
path.join(cwd, 'src-tauri', 'Cargo.toml'),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
const serviceDependency = cargoManifest
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
|
|
||||||
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
|
|
||||||
if (sourcePath) {
|
|
||||||
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
|
|
||||||
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
|
|
||||||
execFileSync(
|
|
||||||
'cargo',
|
|
||||||
[
|
|
||||||
'build',
|
|
||||||
'--manifest-path',
|
|
||||||
manifest,
|
|
||||||
'--target-dir',
|
|
||||||
targetDirectory,
|
|
||||||
'--target',
|
|
||||||
SIDECAR_HOST,
|
|
||||||
'--release',
|
|
||||||
'--features',
|
|
||||||
'standalone,client',
|
|
||||||
'--bins',
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit' },
|
|
||||||
)
|
|
||||||
await fsp.mkdir(SERVICE_DIR, { recursive: true })
|
|
||||||
for (const { sourceFile, targetPath } of files) {
|
|
||||||
await fsp.copyFile(
|
|
||||||
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
|
|
||||||
targetPath,
|
|
||||||
)
|
|
||||||
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
|
|
||||||
await updateHashCache(targetPath)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { archiveFile, downloadURL } = resolveServiceRelease(
|
const { archiveFile, downloadURL } = resolveServiceRelease(
|
||||||
cargoManifest,
|
cargoManifest,
|
||||||
SIDECAR_HOST,
|
SIDECAR_HOST,
|
||||||
@@ -639,9 +604,8 @@ async function resolveServiceBundle() {
|
|||||||
const zip = new AdmZip(tempArchive)
|
const zip = new AdmZip(tempArchive)
|
||||||
zip
|
zip
|
||||||
.getEntries()
|
.getEntries()
|
||||||
.forEach(
|
.forEach((entry) =>
|
||||||
(entry) =>
|
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
||||||
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
|
|
||||||
)
|
)
|
||||||
zip.extractAllTo(tempDir, true)
|
zip.extractAllTo(tempDir, true)
|
||||||
} else {
|
} else {
|
||||||
@@ -688,7 +652,7 @@ async function resolveCoreHashes() {
|
|||||||
.digest('hex')
|
.digest('hex')
|
||||||
lines.push(`!define ${define} "${digest}"`)
|
lines.push(`!define ${define} "${digest}"`)
|
||||||
}
|
}
|
||||||
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
|
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
|
||||||
log_success(`Generated ${CORE_HASHES_NSH}`)
|
log_success(`Generated ${CORE_HASHES_NSH}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,11 +661,6 @@ const resolveMmdb = () =>
|
|||||||
file: 'Country.mmdb',
|
file: 'Country.mmdb',
|
||||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
|
||||||
})
|
})
|
||||||
const resolveASNMmdb = () =>
|
|
||||||
resolveResource({
|
|
||||||
file: 'ASN.mmdb',
|
|
||||||
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
|
|
||||||
})
|
|
||||||
const resolveGeosite = () =>
|
const resolveGeosite = () =>
|
||||||
resolveResource({
|
resolveResource({
|
||||||
file: 'geosite.dat',
|
file: 'geosite.dat',
|
||||||
@@ -747,7 +706,6 @@ const tasks = [
|
|||||||
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
|
||||||
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
{ name: 'service', func: resolveServiceBundle, retry: 5 },
|
||||||
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
|
||||||
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
|
|
||||||
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
{ name: 'geosite', func: resolveGeosite, retry: 5 },
|
||||||
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-50
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
|
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm release-version <version>
|
* pnpm release-version <version>
|
||||||
@@ -108,20 +108,17 @@ async function updatePackageVersion(newVersion) {
|
|||||||
'[INFO]: Current package.json version is: ',
|
'[INFO]: Current package.json version is: ',
|
||||||
packageJson.version,
|
packageJson.version,
|
||||||
)
|
)
|
||||||
const versionWithoutV = newVersion.startsWith('v')
|
packageJson.version = newVersion.startsWith('v')
|
||||||
? newVersion.slice(1)
|
? newVersion.slice(1)
|
||||||
: newVersion
|
: newVersion
|
||||||
const updatedData = data.replace(
|
await fs.writeFile(
|
||||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
packageJsonPath,
|
||||||
`$1"${versionWithoutV}"`,
|
JSON.stringify(packageJson, null, 2),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
`[INFO]: package.json version updated to: ${packageJson.version}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (updatedData === data) {
|
|
||||||
throw new Error('version field was not found in package.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
|
|
||||||
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating package.json version:', error)
|
console.error('Error updating package.json version:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -156,34 +153,6 @@ async function updateCargoVersion(newVersion) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCargoLockVersion(newVersion) {
|
|
||||||
const _dirname = process.cwd()
|
|
||||||
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
|
|
||||||
const versionWithoutV = newVersion.startsWith('v')
|
|
||||||
? newVersion.slice(1)
|
|
||||||
: newVersion
|
|
||||||
const packageVersionPattern =
|
|
||||||
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fs.readFile(cargoLockPath, 'utf8')
|
|
||||||
const updatedData = data.replace(
|
|
||||||
packageVersionPattern,
|
|
||||||
`$1"${versionWithoutV}"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updatedData === data) {
|
|
||||||
throw new Error('clash-verge package entry was not found in Cargo.lock')
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
|
|
||||||
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating Cargo.lock version:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTauriConfigVersion(newVersion) {
|
async function updateTauriConfigVersion(newVersion) {
|
||||||
const _dirname = process.cwd()
|
const _dirname = process.cwd()
|
||||||
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
|
||||||
@@ -199,16 +168,13 @@ async function updateTauriConfigVersion(newVersion) {
|
|||||||
tauriConfig.version,
|
tauriConfig.version,
|
||||||
)
|
)
|
||||||
|
|
||||||
const updatedData = data.replace(
|
tauriConfig.version = versionWithoutV
|
||||||
/^(\s*"version"\s*:\s*)"[^"]+"/m,
|
|
||||||
`$1"${versionWithoutV}"`,
|
await fs.writeFile(
|
||||||
|
tauriConfigPath,
|
||||||
|
JSON.stringify(tauriConfig, null, 2),
|
||||||
|
'utf8',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (updatedData === data) {
|
|
||||||
throw new Error('version field was not found in tauri.conf.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
|
|
||||||
console.log(
|
console.log(
|
||||||
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
|
||||||
)
|
)
|
||||||
@@ -273,7 +239,6 @@ async function main(versionArg) {
|
|||||||
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
console.log(`[INFO]: Updating versions to: ${newVersion}`)
|
||||||
await updatePackageVersion(newVersion)
|
await updatePackageVersion(newVersion)
|
||||||
await updateCargoVersion(newVersion)
|
await updateCargoVersion(newVersion)
|
||||||
await updateCargoLockVersion(newVersion)
|
|
||||||
await updateTauriConfigVersion(newVersion)
|
await updateTauriConfigVersion(newVersion)
|
||||||
console.log('[SUCCESS]: All version updates completed successfully!')
|
console.log('[SUCCESS]: All version updates completed successfully!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+19
-41
@@ -30,51 +30,29 @@ function is_valid_ip() {
|
|||||||
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
|
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
|
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
|
||||||
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
|
||||||
|
|
||||||
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
|
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
||||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
||||||
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
|
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
||||||
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
|
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
||||||
|
')
|
||||||
|
|
||||||
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
|
original_dns=$(networksetup -getdnsservers "$hardware_port")
|
||||||
|
|
||||||
backup=".original_dns/$service_id"
|
is_valid_dns=false
|
||||||
mkdir -p .original_dns || exit 1
|
for ip in $original_dns; do
|
||||||
# Older releases kept a single backup for whichever service was primary.
|
ip=$(echo "$ip" | tr -d '[:space:]')
|
||||||
if [ -f .original_dns.txt ]; then
|
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
|
||||||
if [ -f "$backup" ]; then
|
is_valid_dns=true
|
||||||
rm -f .original_dns.txt || exit 1
|
break
|
||||||
else
|
|
||||||
mv .original_dns.txt "$backup" || exit 1
|
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$is_valid_dns" = false ]; then
|
||||||
|
echo "empty" >.original_dns.txt
|
||||||
|
else
|
||||||
|
echo "$original_dns" >.original_dns.txt
|
||||||
fi
|
fi
|
||||||
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
|
networksetup -setdnsservers "$hardware_port" "$1"
|
||||||
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
|
|
||||||
|
|||||||
+8
-57
@@ -1,62 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
|
nic=$(route -n get default | grep "interface" | awk '{print $2}')
|
||||||
|
|
||||||
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
|
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
|
||||||
function sc_value() {
|
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
|
||||||
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
|
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
|
||||||
}
|
')
|
||||||
|
|
||||||
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
|
|
||||||
function dns_of() {
|
|
||||||
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
|
|
||||||
dns=$(networksetup -getdnsservers "$1") || return 1
|
|
||||||
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
|
|
||||||
echo "empty"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
[ -n "$dns" ] || return 1
|
|
||||||
while read -r line; do
|
|
||||||
[[ $line =~ $ip ]] || return 1
|
|
||||||
done <<<"$dns"
|
|
||||||
echo "$dns"
|
|
||||||
}
|
|
||||||
|
|
||||||
result=0
|
|
||||||
# Older releases kept a single backup for whichever service was primary.
|
|
||||||
if [ -f .original_dns.txt ]; then
|
if [ -f .original_dns.txt ]; then
|
||||||
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
|
original_dns=$(cat .original_dns.txt)
|
||||||
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
|
networksetup -setdnsservers "$hardware_port" $original_dns
|
||||||
result=1
|
rm -rf .original_dns.txt
|
||||||
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
|
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,13 +14,22 @@ async function resolveUpdater() {
|
|||||||
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
const options = { owner: context.repo.owner, repo: context.repo.repo }
|
||||||
const github = getOctokit(process.env.GITHUB_TOKEN)
|
const github = getOctokit(process.env.GITHUB_TOKEN)
|
||||||
|
|
||||||
const { data: latestRelease } =
|
const { data: tags } = await github.rest.repos.listTags({
|
||||||
await github.rest.repos.getLatestRelease(options)
|
...options,
|
||||||
const tag = { name: latestRelease.tag_name }
|
per_page: 10,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = tags.find((t) => t.name.startsWith('v'))
|
||||||
|
|
||||||
console.log(tag)
|
console.log(tag)
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
|
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
|
||||||
|
...options,
|
||||||
|
tag: tag.name,
|
||||||
|
})
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
notes: await resolveUpdateLog(tag.name), // use Changelog.md
|
||||||
|
|||||||
+2
-3
@@ -40,11 +40,10 @@ async function resolveUpdater() {
|
|||||||
const tags = allTags
|
const tags = allTags
|
||||||
console.log(`Retrieved ${tags.length} tags in total`)
|
console.log(`Retrieved ${tags.length} tags in total`)
|
||||||
|
|
||||||
|
const stableTagRegex = /^v\d+\.\d+\.\d+$/
|
||||||
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
|
||||||
|
|
||||||
const { data: latestRelease } =
|
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
|
||||||
await github.rest.repos.getLatestRelease(options)
|
|
||||||
const stableTag = { name: latestRelease.tag_name }
|
|
||||||
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
|
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
|
||||||
|
|
||||||
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
console.log('All tags:', tags.map((t) => t.name).join(', '))
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Backend Architecture Guardrails
|
|
||||||
|
|
||||||
These rules supplement the repository's root `AGENTS.md`.
|
|
||||||
|
|
||||||
- **Configuration ownership.** `config` owns source configuration, drafts and
|
|
||||||
persistence. The effects layer owns patch orchestration. Rollbackable
|
|
||||||
transactions publish state after persistence and commit; already applied
|
|
||||||
runtime and independently committed state remain authoritative even when a
|
|
||||||
later step fails. Preserve that state, its notifications and the error.
|
|
||||||
- **Effect ownership.** The declarative registry classifies every Verge patch field,
|
|
||||||
including fields without effects and platform-specific fields. Effects
|
|
||||||
reconcile toward the requested state with explicit dependencies and ordering.
|
|
||||||
Configuration rollback does not imply reversal of OS or lifecycle effects.
|
|
||||||
- **Shared wire contract.** Notifications are typed. Event names, status values
|
|
||||||
and payload shapes stay compatible with frontend producers and consumers.
|
|
||||||
- **Synchronization ownership.** Configuration-write serialization precedes
|
|
||||||
configuration-update ownership, which precedes Core lifecycle ownership.
|
|
||||||
When both are held, the lifecycle lock precedes the DNS lock. Independent
|
|
||||||
transactions retain their ownership boundaries; asynchronous work does not
|
|
||||||
hold synchronous data guards across suspension.
|
|
||||||
- **Retry ownership.** Bounded retries share backend policy while preserving
|
|
||||||
attempts, delays and terminal errors. Background monitors own their lifetime
|
|
||||||
and cancellation separately; waiting for a condition or traversing distinct
|
|
||||||
candidates is not a retry policy.
|
|
||||||
+30
-27
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "clash-verge"
|
name = "clash-verge"
|
||||||
version = "2.5.8"
|
version = "2.5.4"
|
||||||
description = "clash verge"
|
description = "clash verge"
|
||||||
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
|||||||
default-run = "clash-verge"
|
default-run = "clash-verge"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.99"
|
rust-version = "1.95"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
|
|||||||
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
identifier = "io.github.clash-verge-rev.clash-verge-rev"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.7.1", features = [] }
|
tauri-build = { version = "2.6.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clash-verge-draft = { workspace = true }
|
clash-verge-draft = { workspace = true }
|
||||||
@@ -59,13 +59,14 @@ smartstring = { workspace = true, features = ["serde"] }
|
|||||||
bitflags = { workspace = true }
|
bitflags = { workspace = true }
|
||||||
socket2 = { workspace = true }
|
socket2 = { workspace = true }
|
||||||
warp = { version = "0.4.3", features = ["server"] }
|
warp = { version = "0.4.3", features = ["server"] }
|
||||||
open = "5.4.4"
|
open = "5.4.1"
|
||||||
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
|
||||||
dirs = "7.0.0"
|
dirs = "7.0.0"
|
||||||
dunce = "1.0.5"
|
dunce = "1.0.5"
|
||||||
nanoid = "0.5"
|
nanoid = "0.5"
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
boa_engine = "0.22.0"
|
boa_engine = "0.21.1"
|
||||||
|
once_cell = { version = "1.21.4", features = ["parking_lot"] }
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
@@ -73,49 +74,51 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
|||||||
"guard",
|
"guard",
|
||||||
] }
|
] }
|
||||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||||
tauri-plugin-shell = "2.4.0"
|
tauri-plugin-shell = "2.3.5"
|
||||||
tauri-plugin-opener = "2.7.0"
|
tauri-plugin-opener = "2.5.4"
|
||||||
tauri-plugin-dialog = "2.8.1"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
tauri-plugin-fs = "2.6.0"
|
tauri-plugin-fs = "2.5.1"
|
||||||
tauri-plugin-process = "2.4.0"
|
tauri-plugin-process = "2.3.1"
|
||||||
tauri-plugin-deep-link = "2.6.1"
|
tauri-plugin-deep-link = "2.4.9"
|
||||||
tauri-plugin-window-state = "2.5.0"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
flate2 = "1.1.10"
|
flate2 = "1.1.9"
|
||||||
reqwest_dav = "0.3.3"
|
reqwest_dav = "0.3.3"
|
||||||
aes-gcm = { version = "0.11.1" }
|
aes-gcm = { version = "0.11.0" }
|
||||||
base64 = "0.23.1"
|
base64 = "0.23.1"
|
||||||
minisign-verify = "0.3.0"
|
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
gethostname = "1.1.0"
|
gethostname = "1.1.0"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
tauri-plugin-notification = "2.5.1"
|
tauri-plugin-notification = "2.3.3"
|
||||||
tokio-stream = "0.1.19"
|
tokio-stream = "0.1.19"
|
||||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||||
tauri-plugin-http = "2.8.0"
|
tauri-plugin-http = "2.5.9"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tauri-plugin-devtools = { version = "2.2.2", optional = true }
|
tauri-plugin-devtools = { version = "2.1.0", optional = true }
|
||||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
|
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", features = [
|
||||||
"client",
|
"client",
|
||||||
] }
|
] }
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
tokio-rustls = "0.26.6"
|
tokio-rustls = "0.26.4"
|
||||||
rustls = { version = "0.23.45", features = ["ring"] }
|
rustls = { version = "0.23.43", features = ["ring"] }
|
||||||
webpki-roots = "1.0.9"
|
webpki-roots = "1.0.9"
|
||||||
dark-light = "3.0.0"
|
dark-light = "3.0.0"
|
||||||
bytes = "1.12.1"
|
bytes = "1.12.1"
|
||||||
tokio-util = { version = "0.7.19", features = ["time"] }
|
tokio-util = { version = "0.7.19", features = ["time"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.189"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
|
clash-verge-widget = { workspace = true }
|
||||||
objc2 = "0.6.5"
|
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
|
||||||
|
core-foundation = "0.9"
|
||||||
|
system-configuration = "0.7"
|
||||||
|
objc2 = "0.6.4"
|
||||||
objc2-foundation = { version = "0.3.2", features = [
|
objc2-foundation = { version = "0.3.2", features = [
|
||||||
"NSString",
|
"NSString",
|
||||||
"NSDictionary",
|
"NSDictionary",
|
||||||
@@ -146,9 +149,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
|
|||||||
windows-sys.workspace = true
|
windows-sys.workspace = true
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-autostart = "2.7.0"
|
tauri-plugin-autostart = "2.5.1"
|
||||||
tauri-plugin-global-shortcut = "2.4.0"
|
tauri-plugin-global-shortcut = "2.3.2"
|
||||||
tauri-plugin-updater = "2.13.1"
|
tauri-plugin-updater = "2.10.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { workspace = true }
|
criterion = { workspace = true }
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
chmod +x /usr/bin/clash-verge-service-install
|
|
||||||
chmod +x /usr/bin/clash-verge-service-uninstall
|
|
||||||
chmod +x /usr/bin/clash-verge-service
|
|
||||||
|
|
||||||
# The enforcement file exists in both Enforcing and Permissive mode.
|
|
||||||
if [ ! -e /sys/fs/selinux/enforce ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
|
|
||||||
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
|
|
||||||
for service in clash-verge-service clash-verge-service-dev; do
|
|
||||||
for directory in bin cores; do
|
|
||||||
target="/var/lib/$service/$directory"
|
|
||||||
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
|
|
||||||
echo "Refusing to relabel a symlinked service directory: $target" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
spec="$target(/.*)?"
|
|
||||||
# Older semanage versions reject -a when an exact rule already exists.
|
|
||||||
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
|
|
||||||
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
|
|
||||||
printf '%s\n' "$add_error" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -d "$target" ]; then
|
|
||||||
/usr/sbin/restorecon -R "$target"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
@@ -901,7 +901,6 @@ Section Install
|
|||||||
|
|
||||||
nsExec::Exec 'netsh int tcp res'
|
nsExec::Exec 'netsh int tcp res'
|
||||||
|
|
||||||
!include "Win\RestartManager.nsh"
|
|
||||||
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
|
||||||
!insertmacro CheckAllVergeProcesses
|
!insertmacro CheckAllVergeProcesses
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
|
|
||||||
//! the payload is derived from the same serializers the app uses at runtime.
|
|
||||||
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
|
|
||||||
//! TypeScript artifact.
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
println!("{}", app_lib::frontend_wire_contract());
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{
|
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
|
||||||
cmd::StringifyErr as _,
|
|
||||||
core::{SilentUpdater, updater::DownloadEvent},
|
|
||||||
feat,
|
|
||||||
utils::dirs,
|
|
||||||
};
|
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use tauri::{AppHandle, Manager as _, ipc::Channel};
|
use tauri::{AppHandle, Manager as _};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_app_dir() -> CmdResult<()> {
|
pub async fn open_app_dir() -> CmdResult<()> {
|
||||||
@@ -49,25 +44,6 @@ pub async fn restart_app() -> CmdResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn install_update(
|
|
||||||
app_handle: AppHandle,
|
|
||||||
version: String,
|
|
||||||
on_event: Channel<DownloadEvent>,
|
|
||||||
) -> CmdResult<bool> {
|
|
||||||
SilentUpdater::global()
|
|
||||||
.install_update(&app_handle, &version, |event| {
|
|
||||||
let _ = on_event.send(event);
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.stringify_err()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn cancel_update_download() {
|
|
||||||
SilentUpdater::global().cancel_download();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_dir() -> CmdResult<String> {
|
pub fn get_app_dir() -> CmdResult<String> {
|
||||||
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
|
||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::feat;
|
use crate::feat;
|
||||||
use crate::utils::{dirs, yaml_emitter};
|
use crate::utils::{dirs, yaml_emitter};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -57,14 +56,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
|||||||
|
|
||||||
match CoreManager::global().restart_core().await {
|
match CoreManager::global().restart_core().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
|
handle::Handle::notice_message("config_core::change_success", clash_core);
|
||||||
handle::Handle::refresh_clash();
|
handle::Handle::refresh_clash();
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let failed = err.context("core changed but failed to restart");
|
let failed = err.context("core changed but failed to restart");
|
||||||
let error_msg: String = format!("{failed:#}").into();
|
let error_msg: String = format!("{failed:#}").into();
|
||||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
|
||||||
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
|
||||||
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg: String = format!("{err:#}").into();
|
let error_msg: String = format!("{err:#}").into();
|
||||||
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
logging!(error, Type::Core, "failed to change core: {error_msg}");
|
||||||
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
|
handle::Handle::notice_message("config_core::change_error", error_msg);
|
||||||
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,12 +133,8 @@ pub fn take_dns_override_notice() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_dns_override(
|
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
|
||||||
profile_uid: String,
|
feat::set_dns_override(enabled, confirmation)
|
||||||
enabled: bool,
|
|
||||||
confirmation: Option<String>,
|
|
||||||
) -> CmdResult<feat::DnsOverrideOutcome> {
|
|
||||||
feat::set_dns_override(profile_uid, enabled, confirmation)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
|
||||||
}
|
}
|
||||||
@@ -150,26 +145,16 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
|
|||||||
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
|
||||||
|
|
||||||
if !dns_path.exists() {
|
if !dns_path.exists() {
|
||||||
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
|
logging!(warn, Type::Config, "DNS config file not found");
|
||||||
return Err("DNS config file not found".into());
|
return Err("DNS config file not found".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
|
||||||
logging!(
|
logging!(error, Type::Config, "Failed to read DNS config: {e}");
|
||||||
error,
|
|
||||||
Type::Config,
|
|
||||||
"Failed to read DNS config {}: {e}",
|
|
||||||
dns_path.display()
|
|
||||||
);
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
|
||||||
logging!(
|
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
|
||||||
error,
|
|
||||||
Type::Config,
|
|
||||||
"Failed to parse DNS config {}: {e}",
|
|
||||||
dns_path.display()
|
|
||||||
);
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut patch = serde_yaml_ng::Mapping::new();
|
let mut patch = serde_yaml_ng::Mapping::new();
|
||||||
@@ -225,8 +210,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
|||||||
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fragment, not a runnable config; the merged result is validated on apply.
|
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
|
||||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
|
|
||||||
.await
|
.await
|
||||||
.stringify_err()
|
.stringify_err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::core::lightweight;
|
use crate::module::lightweight;
|
||||||
|
|
||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
||||||
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
||||||
use crate::config::profiles;
|
use crate::config::profiles;
|
||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{
|
config::{
|
||||||
@@ -283,7 +282,7 @@ async fn handle_update_error<E: std::fmt::Display>(
|
|||||||
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
||||||
discard_and_restore(current_profile).await?;
|
discard_and_restore(current_profile).await?;
|
||||||
let message: String = e.to_string().into();
|
let message: String = e.to_string().into();
|
||||||
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
|
handle::Handle::notice_message("config_validate::boot_error", message.clone());
|
||||||
Ok(ValidationOutcome::invalid_from_message(message))
|
Ok(ValidationOutcome::invalid_from_message(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-72
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
core::{
|
core::{
|
||||||
handle::Handle,
|
handle::Handle,
|
||||||
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
|
||||||
tray::Tray,
|
tray::Tray,
|
||||||
},
|
},
|
||||||
process::AsyncHandler,
|
process::AsyncHandler,
|
||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, HashSet},
|
collections::HashSet,
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,87 +50,20 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
|
|||||||
.collect()
|
.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]
|
#[tauri::command]
|
||||||
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
|
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 runtime = Config::runtime().await;
|
||||||
let latest_runtime = runtime.latest_arc();
|
let latest_runtime = runtime.latest_arc();
|
||||||
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
|
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 mihomo = Handle::mihomo();
|
||||||
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
|
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
|
||||||
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()?;
|
let proxies = proxies.stringify_err()?;
|
||||||
|
|
||||||
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
Ok(ProxyViewBuilder::build(ProxyViewInput {
|
||||||
runtime_group_order,
|
runtime_group_order,
|
||||||
group_scopes,
|
|
||||||
proxies,
|
proxies,
|
||||||
providers,
|
providers: providers.ok(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +84,6 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_tray_sync_loop() {
|
async fn run_tray_sync_loop() {
|
||||||
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
|
|
||||||
loop {
|
loop {
|
||||||
match Tray::global().update_menu().await {
|
match Tray::global().update_menu().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -177,3 +109,31 @@ async fn run_tray_sync_loop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
mod tests {
|
||||||
|
use serde_yaml_ng::Value;
|
||||||
|
|
||||||
|
use super::runtime_group_order;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_order_keeps_first_non_empty_non_global_name() {
|
||||||
|
let config: Value = serde_yaml_ng::from_str(
|
||||||
|
r#"
|
||||||
|
proxy-groups:
|
||||||
|
- name: Beta
|
||||||
|
- name: ""
|
||||||
|
- name: GLOBAL
|
||||||
|
- name: " Alpha "
|
||||||
|
- name: Beta
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("parse runtime");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_group_order(config.as_mapping()),
|
||||||
|
["Beta".to_owned(), " Alpha ".to_owned()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
|
|||||||
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn take_discarded_keys_notice() -> Option<String> {
|
|
||||||
crate::enhance::take_discarded_keys_notice()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
|
||||||
let runtime = Config::runtime().await;
|
let runtime = Config::runtime().await;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ use crate::{
|
|||||||
cmd::StringifyErr as _,
|
cmd::StringifyErr as _,
|
||||||
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
|
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
|
||||||
config::{Config, IProfiles, PrfItem},
|
config::{Config, IProfiles, PrfItem},
|
||||||
core::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
|
||||||
core::{
|
core::{
|
||||||
CoreManager, handle,
|
CoreManager, handle,
|
||||||
validate::{CoreConfigValidator, ValidationOutcome},
|
validate::{CoreConfigValidator, ValidationOutcome},
|
||||||
},
|
},
|
||||||
|
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
|
||||||
utils::dirs,
|
utils::dirs,
|
||||||
};
|
};
|
||||||
use clash_verge_logging::{Type, logging, logging_error};
|
use clash_verge_logging::{Type, logging, logging_error};
|
||||||
|
|||||||
+18
-167
@@ -8,81 +8,26 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
|
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
|
||||||
pub enum ServiceInstallOutcome {
|
|
||||||
Installed,
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
Sidecar {
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
|
|
||||||
let manager = CoreManager::global();
|
let manager = CoreManager::global();
|
||||||
let result = {
|
let _lifecycle = manager.lifecycle_lock.lock().await;
|
||||||
let _lifecycle = manager.lifecycle_lock.lock().await;
|
if matches!(
|
||||||
if matches!(
|
&status,
|
||||||
&status,
|
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
||||||
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
|
) {
|
||||||
) {
|
manager
|
||||||
manager
|
.controlled_stop_core_inner()
|
||||||
.controlled_stop_core_inner()
|
.await
|
||||||
.await
|
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
|
}
|
||||||
}
|
SERVICE_MANAGER
|
||||||
SERVICE_MANAGER.handle_service_status(status).await
|
.handle_service_status(status)
|
||||||
};
|
.await
|
||||||
#[cfg(windows)]
|
|
||||||
return finish_service_installation(result, error_code, |reason| async move {
|
|
||||||
manager.continue_with_sidecar(Some(&reason)).await
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
result
|
|
||||||
.map(|()| ServiceInstallOutcome::Installed)
|
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
.map_err(|error| proxy_aware_coded_error(&error, error_code))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
async fn finish_service_installation<Fallback, FallbackFuture>(
|
|
||||||
installation: anyhow::Result<()>,
|
|
||||||
error_code: &str,
|
|
||||||
fallback: Fallback,
|
|
||||||
) -> CmdResult<ServiceInstallOutcome>
|
|
||||||
where
|
|
||||||
Fallback: FnOnce(String) -> FallbackFuture,
|
|
||||||
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
|
||||||
{
|
|
||||||
let Err(error) = installation else {
|
|
||||||
return Ok(ServiceInstallOutcome::Installed);
|
|
||||||
};
|
|
||||||
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
|
|
||||||
else {
|
|
||||||
return Err(proxy_aware_coded_error(&error, error_code));
|
|
||||||
};
|
|
||||||
let reasons = failure
|
|
||||||
.status
|
|
||||||
.cores
|
|
||||||
.iter()
|
|
||||||
.filter_map(|core| match &core.availability {
|
|
||||||
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if reasons.is_empty() {
|
|
||||||
return Err(proxy_aware_coded_error(&error, error_code));
|
|
||||||
}
|
|
||||||
let reason = reasons.join("\n");
|
|
||||||
fallback(reason.clone())
|
|
||||||
.await
|
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
|
|
||||||
Ok(ServiceInstallOutcome::Sidecar { reason })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
|
pub async fn install_service() -> CmdResult {
|
||||||
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,120 +40,26 @@ pub async fn uninstall_service() -> CmdResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
|
pub async fn reinstall_service() -> CmdResult {
|
||||||
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
pub async fn repair_service() -> CmdResult {
|
||||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn continue_with_sidecar() -> CmdResult {
|
pub async fn continue_with_sidecar() -> CmdResult {
|
||||||
crate::core::CoreManager::global()
|
crate::core::CoreManager::global()
|
||||||
.continue_with_sidecar(None)
|
.continue_with_sidecar()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
|
|
||||||
crate::core::notification::sidecar_failure_snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
|
||||||
crate::core::service::take_service_fallback_notice()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
|
|
||||||
crate::core::CoreManager::global().get_startup_error()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn take_service_repair_notice() -> bool {
|
|
||||||
crate::core::service::take_service_repair_notice()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn take_service_owner_notice() -> Option<String> {
|
|
||||||
crate::core::service::take_service_owner_notice()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn sync_runtime_providers() {
|
pub fn sync_runtime_providers() {
|
||||||
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
|
||||||
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
|
||||||
mod tests {
|
|
||||||
use super::{ServiceInstallOutcome, finish_service_installation};
|
|
||||||
use clash_verge_service_ipc::{
|
|
||||||
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
|
|
||||||
};
|
|
||||||
use std::cell::Cell;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
|
|
||||||
let reason = "core path C:\\ is writable by Everyone";
|
|
||||||
for (availability, fallback_ready) in [
|
|
||||||
(None, false),
|
|
||||||
(Some(CoreAvailability::Missing), false),
|
|
||||||
(Some(CoreAvailability::DigestMismatch), false),
|
|
||||||
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
|
|
||||||
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
|
|
||||||
] {
|
|
||||||
let fallback_called = Cell::new(false);
|
|
||||||
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
|
|
||||||
let error = match availability {
|
|
||||||
Some(availability) => anyhow::Error::new(InstallationVerificationError {
|
|
||||||
status: InstallationStatus {
|
|
||||||
service_sha256: String::new(),
|
|
||||||
protocol: ProtocolInfo::current(),
|
|
||||||
cores: vec![CoreInspection {
|
|
||||||
name: "verge-mihomo.exe".into(),
|
|
||||||
availability,
|
|
||||||
}],
|
|
||||||
core_busy: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
None => anyhow::anyhow!("{reason}"),
|
|
||||||
}
|
|
||||||
.context("install service failed");
|
|
||||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
|
|
||||||
fallback_called.set(true);
|
|
||||||
anyhow::ensure!(fallback_ready, "another core is running");
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(fallback_called.get(), rejected);
|
|
||||||
if rejected && fallback_ready {
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
Ok(ServiceInstallOutcome::Sidecar {
|
|
||||||
reason: format!("verge-mihomo.exe: {reason}")
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let error = result.expect_err("an unavailable fallback must remain an error");
|
|
||||||
assert_eq!(
|
|
||||||
error.code.as_deref(),
|
|
||||||
Some(if rejected {
|
|
||||||
"SERVICE_SIDECAR_FAILED"
|
|
||||||
} else {
|
|
||||||
"SERVICE_INSTALL_FAILED"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if rejected {
|
|
||||||
assert!(error.detail.contains("another core is running"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
handle,
|
handle,
|
||||||
validate::{ValidationErrorKind, ValidationOutcome},
|
validate::{ValidationErrorKind, ValidationOutcome},
|
||||||
@@ -12,27 +11,27 @@ pub enum ValidationNoticeTarget {
|
|||||||
Script,
|
Script,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
|
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
|
ValidationErrorKind::FileMissing => "config_validate::file_not_found",
|
||||||
ValidationErrorKind::FileRead => match target {
|
ValidationErrorKind::FileRead => match target {
|
||||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||||
_ => NoticeStatus::ConfigValidateYamlReadError,
|
_ => "config_validate::yaml_read_error",
|
||||||
},
|
},
|
||||||
ValidationErrorKind::YamlSyntax => match target {
|
ValidationErrorKind::YamlSyntax => match target {
|
||||||
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
|
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error",
|
||||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||||
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
|
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error",
|
||||||
},
|
},
|
||||||
ValidationErrorKind::YamlMapping => match target {
|
ValidationErrorKind::YamlMapping => match target {
|
||||||
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
|
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error",
|
||||||
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
|
ValidationNoticeTarget::Script => "config_validate::script_error",
|
||||||
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
|
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error",
|
||||||
},
|
},
|
||||||
ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
|
ValidationErrorKind::ScriptSyntax => "config_validate::script_syntax_error",
|
||||||
ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
|
ValidationErrorKind::ScriptMissingMain => "config_validate::script_missing_main",
|
||||||
ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
|
ValidationErrorKind::ProcessTerminated => "config_validate::process_terminated",
|
||||||
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
|
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => "config_validate::error",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,12 +40,12 @@ pub fn handle_validation_notice(outcome: &ValidationOutcome, target: ValidationN
|
|||||||
ValidationOutcome::Invalid { kind, message } => {
|
ValidationOutcome::Invalid { kind, message } => {
|
||||||
let status = notice_key(*kind, target);
|
let status = notice_key(*kind, target);
|
||||||
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
|
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
|
||||||
handle::Handle::notice(status, message.as_str());
|
handle::Handle::notice_message(status, message.to_owned());
|
||||||
}
|
}
|
||||||
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
|
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
|
||||||
let message = outcome.to_string();
|
let message = outcome.to_string();
|
||||||
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
|
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
|
||||||
handle::Handle::notice(NoticeStatus::ConfigValidateError, message.as_str());
|
handle::Handle::notice_message("config_validate::error", message);
|
||||||
}
|
}
|
||||||
ValidationOutcome::Valid => {}
|
ValidationOutcome::Valid => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
|
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
|
||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
||||||
constants::{files, timing},
|
constants::{files, timing},
|
||||||
@@ -7,7 +6,6 @@ use crate::{
|
|||||||
CoreManager,
|
CoreManager,
|
||||||
handle::{self, Handle},
|
handle::{self, Handle},
|
||||||
listener::MIXED_PORT_KEY,
|
listener::MIXED_PORT_KEY,
|
||||||
runtime_bundle::resolve_provider_path_conflicts,
|
|
||||||
tray,
|
tray,
|
||||||
validate::CoreConfigValidator,
|
validate::CoreConfigValidator,
|
||||||
},
|
},
|
||||||
@@ -70,12 +68,6 @@ impl Config {
|
|||||||
CONFIG_WRITE_LOCK.lock().await
|
CONFIG_WRITE_LOCK.lock().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn try_lock_config_write() -> Result<MutexGuard<'static, ()>> {
|
|
||||||
CONFIG_WRITE_LOCK
|
|
||||||
.try_lock()
|
|
||||||
.map_err(|_| anyhow!("configuration update is already running"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn init_config_before_window() -> Result<()> {
|
pub async fn init_config_before_window() -> Result<()> {
|
||||||
Self::ensure_default_profile_items().await?;
|
Self::ensure_default_profile_items().await?;
|
||||||
|
|
||||||
@@ -113,7 +105,7 @@ impl Config {
|
|||||||
|
|
||||||
if let Some((msg_type, msg_content)) = validation_result {
|
if let Some((msg_type, msg_content)) = validation_result {
|
||||||
sleep(timing::STARTUP_ERROR_DELAY).await;
|
sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
handle::Handle::notice(msg_type, msg_content.as_str());
|
handle::Handle::notice_message(msg_type, msg_content);
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::runtime().await.apply();
|
Self::runtime().await.apply();
|
||||||
@@ -144,14 +136,14 @@ impl Config {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_and_validate() -> Result<Option<(NoticeStatus, String)>> {
|
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
|
||||||
if let Err(err) = Self::generate().await {
|
if let Err(err) = Self::generate().await {
|
||||||
let error_msg: String = err.to_string().into();
|
let error_msg: String = err.to_string().into();
|
||||||
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
|
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
.use_default_config("config_validate::boot_error", &error_msg)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
|
return Ok(Some(("config_validate::boot_error", error_msg)));
|
||||||
}
|
}
|
||||||
logging!(debug, Type::Config, "生成运行时配置成功");
|
logging!(debug, Type::Config, "生成运行时配置成功");
|
||||||
|
|
||||||
@@ -178,25 +170,25 @@ impl Config {
|
|||||||
error_msg
|
error_msg
|
||||||
);
|
);
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
|
.use_default_config("config_validate::boot_error", &error_msg)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
|
Ok(Some(("config_validate::boot_error", error_msg)))
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
|
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
|
.use_default_config("config_validate::process_terminated", "")
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, String::new())))
|
Ok(Some(("config_validate::process_terminated", String::new())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
|
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
|
||||||
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
|
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
|
||||||
CoreManager::global()
|
CoreManager::global()
|
||||||
.use_default_config(NoticeStatus::ConfigValidateError, "")
|
.use_default_config("config_validate::error", "")
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
|
Ok(Some(("config_validate::error", String::new())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +228,6 @@ impl Config {
|
|||||||
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
|
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
|
||||||
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
|
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
|
||||||
|
|
||||||
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
|
|
||||||
sanitize_tunnels_proxy(&mut config);
|
sanitize_tunnels_proxy(&mut config);
|
||||||
// Apply only to generated core config so the saved choice survives the next launch.
|
// Apply only to generated core config so the saved choice survives the next launch.
|
||||||
if let Some(port) = MixedPort::session_fallback() {
|
if let Some(port) = MixedPort::session_fallback() {
|
||||||
|
|||||||
+40
-140
@@ -1,7 +1,5 @@
|
|||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_yaml_ng::{Mapping, Value};
|
use serde_yaml_ng::{Mapping, Value};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
@@ -56,7 +54,6 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct DnsOverrideState {
|
pub(crate) struct DnsOverrideState {
|
||||||
profile_uid: String,
|
|
||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
requested: bool,
|
requested: bool,
|
||||||
@@ -64,10 +61,9 @@ pub(crate) struct DnsOverrideState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DnsOverrideState {
|
impl DnsOverrideState {
|
||||||
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
|
||||||
let enabled = requested && (source.is_none() || source == confirmation);
|
let enabled = requested && (source.is_none() || source == confirmation);
|
||||||
Self {
|
Self {
|
||||||
profile_uid: profile_uid.into(),
|
|
||||||
source,
|
source,
|
||||||
enabled,
|
enabled,
|
||||||
requested,
|
requested,
|
||||||
@@ -76,55 +72,24 @@ impl DnsOverrideState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_to(&self, verge: &mut IVerge) -> bool {
|
fn apply_to(&self, verge: &mut IVerge) -> bool {
|
||||||
if self.profile_uid.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let settings = verge.dns_settings_for(&self.profile_uid);
|
|
||||||
// A later settings write must not be overwritten by an earlier runtime generation.
|
// A later settings write must not be overwritten by an earlier runtime generation.
|
||||||
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
|
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|
||||||
return false;
|
|| verge.dns_override_confirmation != self.confirmation
|
||||||
}
|
|
||||||
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
|
|
||||||
if self.enabled == self.requested
|
|
||||||
&& !clear_confirmation
|
|
||||||
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
verge.profile_dns_settings.insert(
|
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
|
||||||
self.profile_uid.clone(),
|
if self.enabled == self.requested && !clear_confirmation {
|
||||||
ProfileDnsSettings {
|
return false;
|
||||||
enabled: self.enabled,
|
}
|
||||||
confirmation: if clear_confirmation {
|
verge.enable_dns_settings = Some(self.enabled);
|
||||||
None
|
if clear_confirmation {
|
||||||
} else {
|
verge.dns_override_confirmation = None;
|
||||||
self.confirmation.clone()
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
||||||
pub struct ProfileDnsSettings {
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub confirmation: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IVerge {
|
|
||||||
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
|
|
||||||
self.profile_dns_settings
|
|
||||||
.get(profile_uid)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(ProfileDnsSettings {
|
|
||||||
enabled: self.enable_dns_settings.unwrap_or(false),
|
|
||||||
confirmation: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub(crate) async fn sync_dns_override() -> Result<()> {
|
pub(crate) async fn sync_dns_override() -> Result<()> {
|
||||||
let _config_write = Self::lock_config_write().await;
|
let _config_write = Self::lock_config_write().await;
|
||||||
@@ -139,20 +104,19 @@ impl Config {
|
|||||||
}
|
}
|
||||||
// The runtime is already committed; a disk failure cannot undo its effective settings.
|
// The runtime is already committed; a disk failure cannot undo its effective settings.
|
||||||
transaction.commit();
|
transaction.commit();
|
||||||
|
Handle::refresh_verge();
|
||||||
if state.requested && !state.enabled {
|
if state.requested && !state.enabled {
|
||||||
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
|
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
|
||||||
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
|
Handle::notice_message("dns_override::auto_disabled", "");
|
||||||
}
|
}
|
||||||
let result = verge.data_arc().save_file().await;
|
verge.data_arc().save_file().await
|
||||||
Handle::refresh_verge();
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
|
use super::{DnsOverrideState, dns_override_source};
|
||||||
use crate::config::IVerge;
|
use crate::config::IVerge;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
@@ -188,46 +152,35 @@ mod tests {
|
|||||||
let reordered =
|
let reordered =
|
||||||
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
|
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
|
||||||
assert_eq!(source, dns_override_source("one", &reordered)?);
|
assert_eq!(source, dns_override_source("one", &reordered)?);
|
||||||
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
|
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
|
||||||
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
|
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
|
||||||
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
|
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
|
||||||
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
|
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
|
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
|
||||||
let source = Some("provider-dns".into());
|
let source = Some("provider-dns".into());
|
||||||
let confirmed = IVerge {
|
let confirmed = IVerge {
|
||||||
profile_dns_settings: [(
|
enable_dns_settings: Some(true),
|
||||||
"one".into(),
|
dns_override_confirmation: source.clone(),
|
||||||
ProfileDnsSettings {
|
|
||||||
enabled: true,
|
|
||||||
confirmation: source.clone(),
|
|
||||||
},
|
|
||||||
)]
|
|
||||||
.into(),
|
|
||||||
..IVerge::default()
|
..IVerge::default()
|
||||||
};
|
};
|
||||||
let saved = serde_yaml_ng::to_string(&confirmed)?;
|
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
|
||||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
for saved in [
|
||||||
let settings = restarted.dns_settings_for("one");
|
serde_yaml_ng::to_string(&confirmed)?,
|
||||||
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
|
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
||||||
assert!(state.enabled);
|
|
||||||
assert!(!state.apply_to(&mut restarted));
|
|
||||||
for (saved, source) in [
|
|
||||||
(saved, Some("updated".into())),
|
|
||||||
(
|
|
||||||
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
|
|
||||||
source,
|
|
||||||
),
|
|
||||||
] {
|
] {
|
||||||
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
|
||||||
let settings = restarted.dns_settings_for("one");
|
let state = DnsOverrideState::new(
|
||||||
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
|
source.clone(),
|
||||||
|
restarted.enable_dns_settings.unwrap_or(false),
|
||||||
|
restarted.dns_override_confirmation.clone(),
|
||||||
|
);
|
||||||
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
assert!(!state.enabled, "a previous session must not bypass startup protection");
|
||||||
assert!(state.apply_to(&mut restarted));
|
assert!(state.apply_to(&mut restarted));
|
||||||
assert!(!restarted.dns_settings_for("one").enabled);
|
assert_eq!(restarted.enable_dns_settings, Some(false));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -238,74 +191,21 @@ mod tests {
|
|||||||
enable_dns_settings: Some(true),
|
enable_dns_settings: Some(true),
|
||||||
..IVerge::default()
|
..IVerge::default()
|
||||||
};
|
};
|
||||||
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
|
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
|
||||||
assert!(state.apply_to(&mut verge));
|
assert!(state.apply_to(&mut verge));
|
||||||
assert!(!verge.dns_settings_for("one").enabled);
|
assert_eq!(verge.enable_dns_settings, Some(false));
|
||||||
assert!(!state.apply_to(&mut verge));
|
assert!(!state.apply_to(&mut verge));
|
||||||
let disabled = DnsOverrideState::new("one", None, false, None);
|
let disabled = DnsOverrideState::new(None, false, None);
|
||||||
assert!(!disabled.apply_to(&mut verge));
|
assert!(!disabled.apply_to(&mut verge));
|
||||||
assert!(!disabled.enabled);
|
assert!(!disabled.enabled);
|
||||||
|
|
||||||
verge.profile_dns_settings.insert(
|
verge.enable_dns_settings = Some(true);
|
||||||
"one".into(),
|
verge.dns_override_confirmation = Some("provider-dns".into());
|
||||||
ProfileDnsSettings {
|
|
||||||
enabled: true,
|
|
||||||
confirmation: Some("provider-dns".into()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert!(!state.apply_to(&mut verge));
|
assert!(!state.apply_to(&mut verge));
|
||||||
let settings = verge.dns_settings_for("one");
|
|
||||||
assert!(settings.enabled);
|
|
||||||
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
|
|
||||||
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
|
|
||||||
assert!(leaving.apply_to(&mut verge));
|
|
||||||
assert!(verge.dns_settings_for("one").confirmation.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
|
|
||||||
let mut verge = IVerge {
|
|
||||||
enable_dns_settings: Some(true),
|
|
||||||
..IVerge::default()
|
|
||||||
};
|
|
||||||
let first = DnsOverrideState::new("one", None, true, None);
|
|
||||||
assert!(first.apply_to(&mut verge));
|
|
||||||
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
|
|
||||||
assert!(protected.apply_to(&mut verge));
|
|
||||||
assert!(verge.dns_settings_for("one").enabled);
|
|
||||||
assert!(!verge.dns_settings_for("two").enabled);
|
|
||||||
assert!(verge.dns_settings_for("unvisited").enabled);
|
|
||||||
assert_eq!(verge.enable_dns_settings, Some(true));
|
assert_eq!(verge.enable_dns_settings, Some(true));
|
||||||
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
|
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
|
||||||
assert!(restarted.dns_settings_for("one").enabled);
|
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
|
||||||
assert!(!restarted.dns_settings_for("two").enabled);
|
assert!(leaving.apply_to(&mut verge));
|
||||||
Ok(())
|
assert!(verge.dns_override_confirmation.is_none());
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
|
|
||||||
let mut verge = IVerge::default();
|
|
||||||
for uid in ["one", "two"] {
|
|
||||||
verge.profile_dns_settings.insert(
|
|
||||||
uid.into(),
|
|
||||||
ProfileDnsSettings {
|
|
||||||
enabled: true,
|
|
||||||
confirmation: Some(uid.into()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for uid in ["one", "two", "one"] {
|
|
||||||
let settings = verge.dns_settings_for(uid);
|
|
||||||
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
|
|
||||||
assert!(state.enabled);
|
|
||||||
assert!(!state.apply_to(&mut verge));
|
|
||||||
}
|
|
||||||
let settings = verge.dns_settings_for("two");
|
|
||||||
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
|
|
||||||
assert!(updated.apply_to(&mut verge));
|
|
||||||
assert!(!verge.dns_settings_for("two").enabled);
|
|
||||||
assert!(verge.dns_settings_for("two").confirmation.is_none());
|
|
||||||
assert!(verge.dns_settings_for("one").enabled);
|
|
||||||
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use super::{Config, IClashTemp, IVerge, MixedPort};
|
use super::{Config, IClashTemp, IVerge, MixedPort};
|
||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::timing,
|
constants::timing,
|
||||||
core::{
|
core::{
|
||||||
@@ -15,9 +14,15 @@ use crate::{
|
|||||||
use anyhow::{Context as _, Result, anyhow, bail};
|
use anyhow::{Context as _, Result, anyhow, bail};
|
||||||
use clash_verge_draft::DraftTransaction;
|
use clash_verge_draft::DraftTransaction;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_yaml_ng::Value;
|
use serde_yaml_ng::Value;
|
||||||
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
net::SocketAddr,
|
||||||
|
str::FromStr as _,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct MixedPortFallback {
|
struct MixedPortFallback {
|
||||||
@@ -25,8 +30,9 @@ struct MixedPortFallback {
|
|||||||
current: u16,
|
current: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
|
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
|
||||||
static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
|
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
|
||||||
@@ -141,12 +147,17 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn block_startup_core(error: &anyhow::Error) {
|
pub(crate) fn block_startup_core(error: &anyhow::Error) {
|
||||||
|
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
|
||||||
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
|
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
|
||||||
report_fallback_error(error.to_string());
|
report_fallback_error(error.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn startup_core_block_reason() -> Option<String> {
|
pub(crate) fn startup_core_block_reason() -> Option<String> {
|
||||||
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
|
||||||
|
STARTUP_CORE_BLOCK_REASON.lock().clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn notify_startup_mixed_port_fallback() {
|
pub(crate) fn notify_startup_mixed_port_fallback() {
|
||||||
@@ -155,8 +166,8 @@ impl Config {
|
|||||||
};
|
};
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
Handle::notice(
|
Handle::notice_message(
|
||||||
NoticeStatus::MixedPortFallback,
|
"mixed_port::fallback",
|
||||||
format!("{},{}", change.original, change.current),
|
format!("{},{}", change.original, change.current),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -185,7 +196,7 @@ fn report_fallback_error(message: String) {
|
|||||||
);
|
);
|
||||||
AsyncHandler::spawn(move || async move {
|
AsyncHandler::spawn(move || async move {
|
||||||
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
|
||||||
Handle::notice(NoticeStatus::MixedPortFallbackError, message);
|
Handle::notice_message("mixed_port::fallback_error", message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ pub struct PrfItem {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub selected: Option<Vec<PrfSelected>>,
|
pub selected: Option<Vec<PrfSelected>>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub extra: Option<PrfExtra>,
|
pub extra: Option<PrfExtra>,
|
||||||
|
|
||||||
@@ -220,7 +217,6 @@ impl PrfItem {
|
|||||||
desc: Some(desc),
|
desc: Some(desc),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
url: None,
|
url: None,
|
||||||
latency_test_urls: None,
|
|
||||||
selected: None,
|
selected: None,
|
||||||
extra: None,
|
extra: None,
|
||||||
option: Some(PrfOption {
|
option: Some(PrfOption {
|
||||||
@@ -400,7 +396,6 @@ impl PrfItem {
|
|||||||
desc: desc.cloned(),
|
desc: desc.cloned(),
|
||||||
file: Some(file),
|
file: Some(file),
|
||||||
url: Some(url.as_str().into()),
|
url: Some(url.as_str().into()),
|
||||||
latency_test_urls: None,
|
|
||||||
selected: None,
|
selected: None,
|
||||||
extra,
|
extra,
|
||||||
option: Some(PrfOption {
|
option: Some(PrfOption {
|
||||||
|
|||||||
+150
-164
@@ -2,8 +2,6 @@ use super::{
|
|||||||
PrfOption,
|
PrfOption,
|
||||||
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
|
||||||
};
|
};
|
||||||
use crate::core::notify::{Refresh, announce};
|
|
||||||
use crate::utils::retry::{RetryError, RetryPolicy, retry, retry_with_state};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{handle, tray::Tray},
|
core::{handle, tray::Tray},
|
||||||
utils::{
|
utils::{
|
||||||
@@ -18,7 +16,6 @@ use parking_lot::Mutex;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::num::NonZeroUsize;
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
path::{Component, Path},
|
path::{Component, Path},
|
||||||
@@ -71,14 +68,8 @@ impl ProfileDeletePlan {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for file in self.files {
|
for file in self.files {
|
||||||
let path = dir.join(file.as_str());
|
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
|
||||||
if let Err(error) = path.remove_if_exists().await {
|
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
|
||||||
logging!(
|
|
||||||
warn,
|
|
||||||
Type::Config,
|
|
||||||
"Failed to remove deleted profile file {}: {error:#}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +239,6 @@ impl IProfiles {
|
|||||||
patch!(each, item, file);
|
patch!(each, item, file);
|
||||||
patch!(each, item, url);
|
patch!(each, item, url);
|
||||||
patch!(each, item, selected);
|
patch!(each, item, selected);
|
||||||
patch!(each, item, latency_test_urls);
|
|
||||||
patch!(each, item, extra);
|
patch!(each, item, extra);
|
||||||
patch!(each, item, updated);
|
patch!(each, item, updated);
|
||||||
patch!(each, item, option);
|
patch!(each, item, option);
|
||||||
@@ -604,21 +594,19 @@ fn is_activation_current(generation: u64) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
|
||||||
let interval = Duration::from_millis(500);
|
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
|
||||||
let attempts =
|
loop {
|
||||||
NonZeroUsize::MIN.saturating_add((MIHOMO_OPERATION_TIMEOUT.as_millis() / interval.as_millis()) as usize + 1);
|
match handle::Handle::mihomo().get_proxies().await {
|
||||||
tokio::time::timeout(
|
Ok(proxies) => return proxies,
|
||||||
MIHOMO_OPERATION_TIMEOUT,
|
Err(err) => {
|
||||||
retry(RetryPolicy::fixed(attempts, interval), |_| async {
|
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err:#}");
|
||||||
handle::Handle::mihomo().get_proxies().await.map_err(|error| {
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {error:#}");
|
}
|
||||||
RetryError::Retry(error)
|
}
|
||||||
})
|
}
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.context("timed out while waiting for mihomo proxies")?
|
.context("timed out while waiting for mihomo proxies")
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
|
||||||
@@ -697,58 +685,51 @@ async fn update_tray_after_activation(generation: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_selected_update<F, Fut>(supersede: bool, edit: F) -> Result<()>
|
|
||||||
where
|
|
||||||
F: FnOnce(IProfiles) -> Fut + Send,
|
|
||||||
Fut: std::future::Future<Output = Result<(IProfiles, bool)>> + Send,
|
|
||||||
{
|
|
||||||
let changed = Config::profiles().await.with_data_modify(edit).await?;
|
|
||||||
if changed {
|
|
||||||
if supersede {
|
|
||||||
supersede_selected_activation();
|
|
||||||
}
|
|
||||||
announce(Refresh::Profiles);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a backend-made selection so the next core start restores it.
|
/// Records a backend-made selection so the next core start restores it.
|
||||||
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
|
||||||
let group_name = String::from(group_name);
|
let group_name = String::from(group_name);
|
||||||
let node = String::from(node);
|
let node = String::from(node);
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
let recorded = Config::profiles()
|
||||||
let Some(current) = profiles.current.clone() else {
|
.await
|
||||||
return Ok((profiles, false));
|
.with_data_modify(move |mut profiles| async move {
|
||||||
};
|
let Some(current) = profiles.current.clone() else {
|
||||||
let Some(item) = profiles
|
return Ok((profiles, false));
|
||||||
.items
|
};
|
||||||
.as_mut()
|
let Some(item) = profiles
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
.items
|
||||||
else {
|
.as_mut()
|
||||||
return Ok((profiles, false));
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
};
|
else {
|
||||||
|
return Ok((profiles, false));
|
||||||
|
};
|
||||||
|
|
||||||
let mut selected = item.selected.clone().unwrap_or_default();
|
let mut selected = item.selected.clone().unwrap_or_default();
|
||||||
match selected
|
match selected
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|entry| entry.name.as_ref() == Some(&group_name))
|
.find(|entry| entry.name.as_ref() == Some(&group_name))
|
||||||
{
|
{
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
if entry.now.as_ref() == Some(&node) {
|
if entry.now.as_ref() == Some(&node) {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
|
}
|
||||||
|
entry.now = Some(node);
|
||||||
}
|
}
|
||||||
entry.now = Some(node);
|
None => selected.push(PrfSelected {
|
||||||
|
name: Some(group_name),
|
||||||
|
now: Some(node),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
None => selected.push(PrfSelected {
|
item.selected = Some(selected);
|
||||||
name: Some(group_name),
|
profiles.save_file().await?;
|
||||||
now: Some(node),
|
Ok((profiles, true))
|
||||||
}),
|
})
|
||||||
}
|
.await?;
|
||||||
item.selected = Some(selected);
|
|
||||||
profiles.save_file().await?;
|
if recorded {
|
||||||
Ok((profiles, true))
|
supersede_selected_activation();
|
||||||
})
|
handle::Handle::refresh_profiles();
|
||||||
.await
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
|
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
|
||||||
@@ -758,28 +739,36 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
|
||||||
persist_selected_update(true, move |mut profiles| async move {
|
let cleared = Config::profiles()
|
||||||
let Some(current) = profiles.current.clone() else {
|
.await
|
||||||
return Ok((profiles, false));
|
.with_data_modify(move |mut profiles| async move {
|
||||||
};
|
let Some(current) = profiles.current.clone() else {
|
||||||
let Some(item) = profiles
|
return Ok((profiles, false));
|
||||||
.items
|
};
|
||||||
.as_mut()
|
let Some(item) = profiles
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
.items
|
||||||
else {
|
.as_mut()
|
||||||
return Ok((profiles, false));
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t)))
|
||||||
};
|
else {
|
||||||
|
return Ok((profiles, false));
|
||||||
|
};
|
||||||
|
|
||||||
let mut selected = item.selected.clone().unwrap_or_default();
|
let mut selected = item.selected.clone().unwrap_or_default();
|
||||||
if !remove_selected_node(&mut selected, group_name) {
|
if !remove_selected_node(&mut selected, group_name) {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
item.selected = (!selected.is_empty()).then_some(selected);
|
item.selected = (!selected.is_empty()).then_some(selected);
|
||||||
profiles.save_file().await?;
|
profiles.save_file().await?;
|
||||||
Ok((profiles, true))
|
Ok((profiles, true))
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
if cleared {
|
||||||
|
supersede_selected_activation();
|
||||||
|
handle::Handle::refresh_profiles();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_reconciled_selected(
|
async fn persist_reconciled_selected(
|
||||||
@@ -792,27 +781,34 @@ async fn persist_reconciled_selected(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let profiles = Config::profiles().await;
|
||||||
let profile_uid = profile_uid.clone();
|
let profile_uid = profile_uid.clone();
|
||||||
let original_selected = original_selected.to_vec();
|
let original_selected = original_selected.to_vec();
|
||||||
persist_selected_update(false, move |mut profiles| async move {
|
let updated = profiles
|
||||||
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
|
.with_data_modify(move |mut profiles| async move {
|
||||||
return Ok((profiles, false));
|
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
|
||||||
}
|
return Ok((profiles, false));
|
||||||
|
}
|
||||||
|
|
||||||
let profile = profiles
|
let profile = profiles
|
||||||
.items
|
.items
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
|
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
|
||||||
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
|
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
|
||||||
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
|
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
|
||||||
return Ok((profiles, false));
|
return Ok((profiles, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.selected = (!selected.is_empty()).then_some(selected);
|
profile.selected = (!selected.is_empty()).then_some(selected);
|
||||||
profiles.save_file().await?;
|
profiles.save_file().await?;
|
||||||
Ok((profiles, true))
|
Ok((profiles, true))
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
handle::Handle::refresh_profiles();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns recorded selections whose groups have not reached the requested node.
|
/// Returns recorded selections whose groups have not reached the requested node.
|
||||||
@@ -834,58 +830,48 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
|
|||||||
/// Retries selections while provider-backed groups finish loading.
|
/// Retries selections while provider-backed groups finish loading.
|
||||||
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
|
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
|
||||||
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
|
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
|
||||||
let attempts = NonZeroUsize::MIN.saturating_add(
|
loop {
|
||||||
(SELECTED_NODES_SETTLE_DEADLINE.as_millis() / SELECTED_NODES_SETTLE_INTERVAL.as_millis()) as usize,
|
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
|
||||||
);
|
if !is_activation_current(generation) {
|
||||||
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
|
return;
|
||||||
let _ = retry_with_state(
|
}
|
||||||
RetryPolicy::fixed(attempts, SELECTED_NODES_SETTLE_INTERVAL),
|
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
|
||||||
(selected, completed, generation, deadline),
|
// Unreachable core: the deadline still applies, so this cannot spin forever.
|
||||||
|state, _| {
|
if Instant::now() >= deadline {
|
||||||
Box::pin(async move {
|
return;
|
||||||
let (selected, completed, generation, deadline) = state;
|
}
|
||||||
if !is_activation_current(*generation) {
|
continue;
|
||||||
return Ok(());
|
};
|
||||||
}
|
if !is_activation_current(generation) {
|
||||||
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
|
return;
|
||||||
return if Instant::now() >= *deadline {
|
}
|
||||||
Ok(())
|
|
||||||
} else {
|
let pending = unsettled_selections(selected, &snapshot);
|
||||||
Err(RetryError::Retry(()))
|
if pending.is_empty() {
|
||||||
};
|
return;
|
||||||
};
|
}
|
||||||
if !is_activation_current(*generation) {
|
if Instant::now() >= deadline {
|
||||||
return Ok(());
|
logging!(
|
||||||
}
|
warn,
|
||||||
let pending = unsettled_selections(selected, &snapshot);
|
Type::Config,
|
||||||
if pending.is_empty() {
|
"gave up putting back {} selected node(s) the core never loaded: {}",
|
||||||
return Ok(());
|
pending.len(),
|
||||||
}
|
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
|
||||||
if Instant::now() >= *deadline {
|
);
|
||||||
logging!(
|
return;
|
||||||
warn,
|
}
|
||||||
Type::Config,
|
|
||||||
"gave up putting back {} selected node(s) the core never loaded: {}",
|
let plan = reconcile_selected_nodes(selected, None, &snapshot);
|
||||||
pending.len(),
|
if apply_activations(&plan.activations, completed, generation)
|
||||||
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
|
.await
|
||||||
);
|
.is_none()
|
||||||
return Ok(());
|
{
|
||||||
}
|
return;
|
||||||
let plan = reconcile_selected_nodes(selected, None, &snapshot);
|
}
|
||||||
if apply_activations(&plan.activations, completed, *generation)
|
if is_activation_current(generation) {
|
||||||
.await
|
handle::Handle::refresh_clash();
|
||||||
.is_none()
|
}
|
||||||
{
|
}
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if is_activation_current(*generation) {
|
|
||||||
announce(Refresh::Clash);
|
|
||||||
}
|
|
||||||
Err(RetryError::Retry(()))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases the first-pass waiter even when restoration exits early.
|
/// Releases the first-pass waiter even when restoration exits early.
|
||||||
@@ -928,7 +914,7 @@ async fn activate_selected_nodes_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_activation_current(generation) {
|
if is_activation_current(generation) {
|
||||||
announce(Refresh::Clash);
|
handle::Handle::refresh_clash();
|
||||||
}
|
}
|
||||||
|
|
||||||
let plan = if needs_confirmation {
|
let plan = if needs_confirmation {
|
||||||
@@ -952,7 +938,7 @@ async fn activate_selected_nodes_worker(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
if confirmed_activated_count > 0 && is_activation_current(generation) {
|
||||||
announce(Refresh::Clash);
|
handle::Handle::refresh_clash();
|
||||||
}
|
}
|
||||||
confirmed_plan
|
confirmed_plan
|
||||||
} else {
|
} else {
|
||||||
@@ -1042,7 +1028,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
|||||||
|
|
||||||
if selected.is_empty() {
|
if selected.is_empty() {
|
||||||
if is_activation_current(generation) {
|
if is_activation_current(generation) {
|
||||||
announce(Refresh::Clash);
|
handle::Handle::refresh_clash();
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1054,7 +1040,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
|
|||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
|
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
|
||||||
// The profile itself is already active even if node restoration failed.
|
// The profile itself is already active even if node restoration failed.
|
||||||
announce(Refresh::Clash);
|
handle::Handle::refresh_clash();
|
||||||
}
|
}
|
||||||
update_tray_after_activation(generation).await;
|
update_tray_after_activation(generation).await;
|
||||||
logging!(debug, Type::Config, "activating selected nodes done!");
|
logging!(debug, Type::Config, "activating selected nodes done!");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
|
|||||||
use std::{io::ErrorKind, path::PathBuf};
|
use std::{io::ErrorKind, path::PathBuf};
|
||||||
|
|
||||||
pub(crate) struct FileSnapshot {
|
pub(crate) struct FileSnapshot {
|
||||||
pub(crate) path: PathBuf,
|
path: PathBuf,
|
||||||
content: Option<Vec<u8>>,
|
content: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,12 @@ pub struct IVerge {
|
|||||||
|
|
||||||
pub enable_bypass_check: Option<bool>,
|
pub enable_bypass_check: Option<bool>,
|
||||||
|
|
||||||
/// Initial DNS override preference for profiles without a saved setting.
|
/// enable dns settings - this controls whether dns_config.yaml is applied
|
||||||
pub enable_dns_settings: Option<bool>,
|
pub enable_dns_settings: Option<bool>,
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
// Force-enable confirmation is valid only for the current app session.
|
||||||
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
|
#[serde(skip)]
|
||||||
|
pub dns_override_confirmation: Option<String>,
|
||||||
|
|
||||||
pub use_default_bypass: Option<bool>,
|
pub use_default_bypass: Option<bool>,
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub mod files {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod tun {
|
pub mod tun {
|
||||||
pub const DEFAULT_STACK: &str = "mips";
|
pub const DEFAULT_STACK: &str = "gvisor";
|
||||||
|
|
||||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use anyhow::Error;
|
|||||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||||
use backon::{ConstantBuilder, Retryable as _};
|
use backon::{ConstantBuilder, Retryable as _};
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
|
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::{
|
use std::{
|
||||||
@@ -11,7 +12,7 @@ use std::{
|
|||||||
env::{consts::OS, temp_dir},
|
env::{consts::OS, temp_dir},
|
||||||
io::Write as _,
|
io::Write as _,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, OnceLock},
|
sync::Arc,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::{fs, time::timeout};
|
use tokio::{fs, time::timeout};
|
||||||
@@ -57,7 +58,7 @@ pub struct WebDavClient {
|
|||||||
|
|
||||||
impl WebDavClient {
|
impl WebDavClient {
|
||||||
pub fn global() -> &'static Self {
|
pub fn global() -> &'static Self {
|
||||||
static WEBDAV_CLIENT: OnceLock<WebDavClient> = OnceLock::new();
|
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
|
||||||
WEBDAV_CLIENT.get_or_init(|| Self {
|
WEBDAV_CLIENT.get_or_init(|| Self {
|
||||||
config: ArcSwapOption::new(None),
|
config: ArcSwapOption::new(None),
|
||||||
clients: ArcSwap::new(Arc::new(HashMap::new())),
|
clients: ArcSwap::new(Arc::new(HashMap::new())),
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
use super::NoticeStatus;
|
|
||||||
use crate::{APP_HANDLE, singleton};
|
use crate::{APP_HANDLE, singleton};
|
||||||
use smartstring::alias::String;
|
use smartstring::alias::String;
|
||||||
use std::sync::{
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
Arc,
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
};
|
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
|
||||||
|
|
||||||
@@ -40,11 +36,23 @@ impl Handle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_clash() {
|
pub fn refresh_clash() {
|
||||||
super::announce(super::Refresh::Clash);
|
#[cfg(target_os = "macos")]
|
||||||
|
clash_verge_widget::reload();
|
||||||
|
Self::send_event(FrontendEvent::RefreshClash);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_verge() {
|
pub fn refresh_verge() {
|
||||||
super::announce(super::Refresh::Verge);
|
#[cfg(target_os = "macos")]
|
||||||
|
clash_verge_widget::reload();
|
||||||
|
Self::send_event(FrontendEvent::RefreshVerge);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_profiles() {
|
||||||
|
Self::send_event(FrontendEvent::RefreshProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_proxy_config() {
|
||||||
|
Self::send_event(FrontendEvent::RefreshProxyConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push a Run State snapshot to the frontend.
|
/// Push a Run State snapshot to the frontend.
|
||||||
@@ -52,6 +60,8 @@ impl Handle {
|
|||||||
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
|
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
|
||||||
/// stopped or that the Service came back.
|
/// stopped or that the Service came back.
|
||||||
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
|
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
clash_verge_widget::reload();
|
||||||
let Ok(state) = serde_json::to_value(state) else {
|
let Ok(state) = serde_json::to_value(state) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -76,10 +86,13 @@ impl Handle {
|
|||||||
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
|
pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
|
||||||
|
let status_str = status.as_ref();
|
||||||
|
let msg_str = msg.into();
|
||||||
|
|
||||||
Self::send_event(FrontendEvent::NoticeMessage {
|
Self::send_event(FrontendEvent::NoticeMessage {
|
||||||
status,
|
status: status_str,
|
||||||
message: message.into(),
|
message: msg_str,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +108,7 @@ impl Handle {
|
|||||||
self.is_exiting.load(Ordering::Acquire)
|
self.is_exiting.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn send_event(event: FrontendEvent) {
|
fn send_event(event: FrontendEvent) {
|
||||||
let handle = Self::global();
|
let handle = Self::global();
|
||||||
if handle.is_exiting() {
|
if handle.is_exiting() {
|
||||||
return;
|
return;
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::core::notify::NoticeStatus;
|
|
||||||
use crate::process::AsyncHandler;
|
use crate::process::AsyncHandler;
|
||||||
use crate::singleton;
|
use crate::singleton;
|
||||||
use crate::utils::notification::{NotificationEvent, notify_event};
|
use crate::utils::notification::{NotificationEvent, notify_event};
|
||||||
use crate::utils::window_manager::WindowManager;
|
use crate::utils::window_manager::WindowManager;
|
||||||
use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_mode, feat};
|
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use clash_verge_logging::{Type, logging};
|
use clash_verge_logging::{Type, logging};
|
||||||
@@ -178,7 +177,7 @@ impl Hotkey {
|
|||||||
"Hotkey profile reactivation failed validation: {}",
|
"Hotkey profile reactivation failed validation: {}",
|
||||||
message.as_str()
|
message.as_str()
|
||||||
);
|
);
|
||||||
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
|
handle::Handle::notice_message("reactivate_profiles::error", message);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
logging!(
|
logging!(
|
||||||
@@ -187,7 +186,7 @@ impl Hotkey {
|
|||||||
"Failed to reactivate subscriptions via hotkey: {}",
|
"Failed to reactivate subscriptions via hotkey: {}",
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
|
handle::Handle::notice_message("reactivate_profiles::error", err.to_string());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,6 +200,7 @@ impl Hotkey {
|
|||||||
HotkeyFunction::Hide => {
|
HotkeyFunction::Hide => {
|
||||||
AsyncHandler::spawn(async move || {
|
AsyncHandler::spawn(async move || {
|
||||||
feat::hide().await;
|
feat::hide().await;
|
||||||
|
notify_event(NotificationEvent::AppHidden).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,10 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
|
|||||||
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
|
let normalized = bind_address
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|address| address.strip_suffix(']'))
|
||||||
|
.unwrap_or(bind_address);
|
||||||
Ok(vec![
|
Ok(vec![
|
||||||
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
|
||||||
])
|
])
|
||||||
@@ -295,7 +298,11 @@ fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
|
|||||||
if port == 0 {
|
if port == 0 {
|
||||||
bail!("listener port must be between 1 and 65535");
|
bail!("listener port must be between 1 and 65535");
|
||||||
}
|
}
|
||||||
let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
|
let host = host
|
||||||
|
.trim()
|
||||||
|
.strip_prefix('[')
|
||||||
|
.and_then(|host| host.strip_suffix(']'))
|
||||||
|
.unwrap_or_else(|| host.trim());
|
||||||
if host.eq_ignore_ascii_case("localhost") {
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user