Compare commits

..
Author SHA1 Message Date
Tunglies 8ad95657bd style(widget): adopt system type styles and control conventions
Replace fixed point sizes with semantic footnote/caption styles, dim
unselected mode labels into a secondary tier, drop the mode track fill
and capsule borders in favor of one capsule language, and reshape the
toggles as borderless system-style pills. Section spacing unifies at
8pt and the error row gains a leading warning glyph. No behavior
change.
2026-09-14 18:50:16 +08:00
Tunglies a544990f9f fix(widget): raise the window via a dedicated deep-link route
Tapping a desktop-widget background activates a tray-only host through
LaunchServices, which never fires the macOS reopen event, so the app
appeared in the Dock without a window. Point the widget tap URL at the
dedicated clash-verge://show route and handle it in the scheme resolver
by raising the main window (including leaving lightweight mode). Bare
and subscription deep links keep their previous behavior.

Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-09-14 15:03:41 +08:00
Tunglies e35a239a06 refactor(widget): resolve all SwiftLint warnings
Split the extension socket client into connect/send/read helpers so
exchange() stays under the complexity and body-length limits, rename the
file-descriptor parameters, drop redundant nil initialization on the
debug preview knobs, and wrap over-length lines. No behavior change.
2026-09-14 13:59:51 +08:00
Tunglies 143ed48aa9 feat(widget): add macOS desktop widget
Ship a native WidgetKit extension (routing mode, TUN and system proxy
toggles) as part of every macOS build. The extension talks to the Rust
backend over an App Group Unix socket with mutual code-signing checks,
reuses the backend i18n catalogs, and stays inert on macOS < 14.

A beforeBundle hook builds, localizes and pre-signs the extension before
Tauri signs the host; pnpm build resolves a single local signing identity
automatically and CI keeps using its injected credentials. An Xcode
style-preview host is included for iterating on the widget layout. The
i18n cleanup scanner now also scans Swift sources so widget-only keys
are not flagged unused.

Signed-off-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-09-14 13:59:40 +08:00
401 changed files with 11430 additions and 18550 deletions
+2 -2
View File
@@ -5,10 +5,10 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.11.2"
"pnpmVersion": "12.3.3"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0",
"version": "1.98.1",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+11 -41
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.21": {
"github/gh-aw-actions/setup@v0.89.1": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.21",
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
}
},
"containers": {
@@ -22,16 +22,6 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -42,16 +32,6 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -62,30 +42,20 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
},
"ghcr.io/github/github-mcp-server:v1.12.2": {
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
"ghcr.io/github/github-mcp-server:v1.12.1": {
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
}
}
}
-8
View File
@@ -30,19 +30,11 @@ jobs:
PR_BODY: ${{ github.event.pull_request.body }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
DRAFT: ${{ github.event.pull_request.draft }}
ASSOCIATION: ${{ github.event.pull_request.author_association }}
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
run: |
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
echo "Draft or no-issue-needed; linkage check skipped."
exit 0
fi
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
# skip-roles/skip-bots trust tier.
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
exit 0
fi
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
echo "Issue reference found."
else
+9 -9
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
@@ -101,7 +101,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -580,7 +580,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
-26
View File
@@ -1,26 +0,0 @@
name: Backend Guardrails
on:
pull_request:
paths:
- "src-tauri/**"
- ".github/workflows/backend-guardrails.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
banned-patterns:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: No Handle::refresh calls in feat
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
- name: No notice_message outside core/notify
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
- name: No manual update-flag chains
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10
with:
version: v0.89.15
version: v0.89.1
+2 -2
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.0
uses: dtolnay/rust-toolchain@1.98.1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
-71
View File
@@ -6,7 +6,6 @@ on:
permissions:
contents: read
pull-requests: read
env:
HUSKY: 0
@@ -86,73 +85,3 @@ jobs:
- name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: scripts/contract-inputs.json
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true'
run: pnpm gen:contract --check
File diff suppressed because one or more lines are too long
+5 -9
View File
@@ -41,17 +41,13 @@ pre-agent-steps:
' }',
].join('\n');
const nonBlockingQuotaBranch = [
' let quotaErrorMsg = null;',
' if (isQuotaExceeded) {',
' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',
' quotaErrorMsg = { action: "stop" };',
' }',
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
' quotaErrorMsg = { action: "stop", exitCode: 0 };',
' }',
' if (quotaErrorMsg) {',
' return quotaErrorMsg;',
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
' return { action: "stop", exitCode: 0 };',
' }',
' return { action: "stop" };',
' }',
].join('\n');
+8 -8
View File
@@ -144,7 +144,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.98.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -691,7 +691,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
-90
View File
@@ -1,90 +0,0 @@
name: SignPath Test
on:
workflow_dispatch:
permissions:
contents: read
actions: read
env:
CARGO_INCREMENTAL: 0
HUSKY: 0
jobs:
sign-windows:
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare build resources
run: pnpm run prebuild x86_64-pc-windows-msvc
- name: Build test installer
shell: pwsh
env:
NODE_OPTIONS: '--max-old-space-size=4096'
run: |
$configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json'
'{"bundle":{"createUpdaterArtifacts":false}}' |
Set-Content -LiteralPath $configPath -Encoding utf8
pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath
- name: Upload unsigned installer
id: unsigned
uses: actions/upload-artifact@v7
with:
name: windows-x64-unsigned
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
if-no-files-found: error
- name: Sign with SignPath test certificate
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '5b298f4d-010d-4bae-b665-70551a58839c'
project-slug: 'clash-verge-rev'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'initial'
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Inspect installer signature
shell: pwsh
run: |
Get-ChildItem -Path signed/*-setup.exe | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if (-not $signature.SignerCertificate) {
throw "No Authenticode signer certificate found for $($_.Name)"
}
$signature | Format-List Path, Status, StatusMessage, SignerCertificate
}
- name: Upload signed test installer
uses: actions/upload-artifact@v7
with:
name: windows-x64-test-signed
path: signed/*-setup.exe
if-no-files-found: error
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1
View File
@@ -17,3 +17,4 @@ target
.vfox
.claude
.zed
/src-tauri/generated/widget/
-1
View File
@@ -5,5 +5,4 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
cargo install --force cargo-make
fi
node scripts/check-contract-changes.mjs --push "$1" || exit $?
cargo make pre-push
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.11.2
rust 1.99.0
pnpm 12.4.1
rust 1.98.1
Generated
+793 -790
View File
File diff suppressed because it is too large Load Diff
+10 -19
View File
@@ -8,6 +8,7 @@ members = [
"crates/tauri-plugin-clash-verge-sysinfo",
"crates/clash-verge-i18n",
"crates/clash-verge-limiter",
"crates/clash-verge-widget",
]
resolver = "2"
@@ -46,10 +47,6 @@ inherits = "fast-release"
codegen-units = 1
split-debuginfo = "unpacked"
[patch.crates-io]
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
[workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -57,32 +54,27 @@ clash-verge-logging = { path = "crates/clash-verge-logging" }
clash-verge-signal = { path = "crates/clash-verge-signal" }
clash-verge-i18n = { path = "crates/clash-verge-i18n" }
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
clash-verge-widget = { path = "crates/clash-verge-widget" }
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tracing = "0.1"
tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.4.1"
tauri = { version = "2.11.5" }
tauri-plugin-clipboard-manager = "2.3.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.2", features = [
tokio = { version = "1.53.1", features = [
"rt-multi-thread",
"macros",
"time",
"sync",
] }
flexi_logger = "0.31.10"
log = "0.4.34"
log = "0.4.33"
regex = "1.13.1"
reqwest = { version = "0.13.5", features = [
"json",
"cookies",
"gzip",
"rustls",
"form",
] }
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
@@ -90,7 +82,7 @@ smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
bitflags = { version = "2.13.1" }
socket2 = "0.6.5"
# *** For Windows platform only ***
@@ -103,8 +95,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_JobObjects"
] }
# *********************************
+76 -5
View File
@@ -1,29 +1,100 @@
## v2.5.9
## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复「首页」网站测速失败后卡片一直显示测试中的问题
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
- 修复 Linux 安装或卸载服务时授权失败的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
+5 -7
View File
@@ -11,14 +11,13 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
<p align="center">
Languages:
<a href="./README.md">简体中文</a> ·
<a href="./docs/README_en.md">English</a> ·
<a href="./docs/README_es.md">Español</a> ·
<a href="./docs/README_fa.md">فارسی</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./docs/README_ja.md">日本語</a> ·
<a href="./docs/README_ko.md">한국어</a> ·
<a href="./docs/README_pt.md">Português</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./README.md">简体中文</a>
<a href="./docs/README_fa.md">فارسی</a>
</p>
## Preview
@@ -32,7 +31,6 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
#### 我应当怎样选择发行版
@@ -52,14 +50,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
#### AI云边 -- 全新架构机场。
- 💻 多次**技术迭代后**全新亮相。
- 🗺 全**高速稳定**正价节点。
- 🌏 **海外团队**,不跑路
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
- 👨‍🦲 团队架构师为**大厂**网络架构师
- 💰 极致**稳定**,亲民价**价格**
- 🌐 全面支持**流媒体及各AI访问**
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "4.2.4"
rust-i18n = "4.2.1"
sys-locale = "0.3.2"
[lints]
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: على وشك الخروج
body: Clash Verge على وشك الخروج.
appHidden:
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
core:
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
@@ -70,8 +71,6 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
@@ -92,3 +91,14 @@ tray:
systemProxy: وكيل النظام
tun: TUN
profile: ملف تعريفي
widget:
unavailable: افتح Clash Verge
failed: فشل التحديث؛ تحقق من Clash Verge
'on': مفعّل
'off': معطّل
unknown: غير معروف
name: توجيه Clash Verge
description: التحكم في وضع التوجيه وTUN ووكيل النظام.
update: تحديث Clash Verge
setting: الإعداد
value: القيمة
+22 -12
View File
@@ -8,8 +8,8 @@ notifications:
body: Auf {mode} umgeschaltet.
systemProxyToggled:
title: Systemproxy
'on': Der Systemproxy wurde aktiviert.
'off': Der Systemproxy wurde deaktiviert.
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Systemproxy
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
@@ -18,8 +18,8 @@ notifications:
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
tunModeToggled:
title: TUN-Modus
'on': Der TUN-Modus wurde aktiviert.
'off': Der TUN-Modus wurde deaktiviert.
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
lightweightModeEntered:
title: Leichtmodus
body: Leichtmodus aktiviert.
@@ -29,16 +29,17 @@ notifications:
appQuit:
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
appHidden:
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
updateReady:
title: Clash-Verge-Update
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
installNow: Jetzt installieren
later: Später
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
core:
alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.'
launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
@@ -92,3 +91,14 @@ tray:
systemProxy: Systemproxy
tun: TUN
profile: Profil
widget:
unavailable: Clash Verge öffnen
failed: Aktualisierung fehlgeschlagen; Clash Verge prüfen
'on': Ein
'off': Aus
unknown: Unbekannt
name: Clash Verge Routing
description: Routingmodus, TUN und Systemproxy steuern.
update: Clash Verge aktualisieren
setting: Einstellung
value: Wert
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
core:
alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.'
launchGuard:
currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
@@ -92,3 +91,14 @@ tray:
systemProxy: System Proxy
tun: TUN
profile: Profile
widget:
unavailable: Open Clash Verge
failed: Update failed; check Clash Verge
'on': 'On'
'off': 'Off'
unknown: Unknown
name: Clash Verge Routing
description: Routing mode, TUN and system proxy controls.
update: Update Clash Verge
setting: Setting
value: Value
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: A punto de salir
body: Clash Verge está a punto de salir.
appHidden:
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
core:
alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.'
launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
@@ -92,3 +91,14 @@ tray:
systemProxy: Proxy del sistema
tun: TUN
profile: Perfil
widget:
unavailable: Abre Clash Verge
failed: Error al actualizar; revisa Clash Verge
'on': Activado
'off': Desactivado
unknown: Desconocido
name: Enrutamiento de Clash Verge
description: Controles del modo de enrutamiento, TUN y proxy del sistema.
update: Actualizar Clash Verge
setting: Ajuste
value: Valor
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: در آستانه خروج
body: Clash Verge در آستانه خروج است.
appHidden:
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
core:
alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.'
launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
@@ -70,8 +71,6 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
@@ -92,3 +91,14 @@ tray:
systemProxy: پروکسی سیستم
tun: TUN
profile: پروفایل
widget:
unavailable: Clash Verge را باز کنید
failed: به‌روزرسانی ناموفق بود؛ Clash Verge را بررسی کنید
'on': روشن
'off': خاموش
unknown: نامشخص
name: مسیریابی Clash Verge
description: کنترل حالت مسیریابی، TUN و پراکسی سیستم.
update: به‌روزرسانی Clash Verge
setting: تنظیم
value: مقدار
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Akan Keluar
body: Clash Verge akan keluar.
appHidden:
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
core:
alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.'
launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
@@ -70,8 +71,6 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
@@ -92,3 +91,14 @@ tray:
systemProxy: Proksi Sistem
tun: TUN
profile: Profil
widget:
unavailable: Buka Clash Verge
failed: Pembaruan gagal; periksa Clash Verge
'on': Aktif
'off': Nonaktif
unknown: Tidak diketahui
name: Perutean Clash Verge
description: Kontrol mode perutean, TUN, dan proksi sistem.
update: Perbarui Clash Verge
setting: Pengaturan
value: Nilai
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 終了間近
body: Clash Verge はまもなく終了します。
appHidden:
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
core:
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
@@ -70,8 +71,6 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
@@ -92,3 +91,14 @@ tray:
systemProxy: システムプロキシ
tun: TUN
profile: プロファイル
widget:
unavailable: Clash Verge を開いてください
failed: 更新失敗。Clash Verge を確認してください
'on': オン
'off': オフ
unknown: 不明
name: Clash Verge ルーティング
description: ルーティングモード、TUN、システムプロキシの操作。
update: Clash Verge を更新
setting: 設定
value: 値
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 곧 종료
body: Clash Verge가 곧 종료됩니다.
appHidden:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
core:
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
@@ -70,8 +71,6 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
@@ -92,3 +91,14 @@ tray:
systemProxy: 시스템 프록시
tun: TUN
profile: 프로필
widget:
unavailable: Clash Verge를 여세요
failed: 업데이트 실패. Clash Verge를 확인하세요
'on': 켜짐
'off': 꺼짐
unknown: 알 수 없음
name: Clash Verge 라우팅
description: 라우팅 모드, TUN 및 시스템 프록시 제어.
update: Clash Verge 업데이트
setting: 설정
value: 값
-94
View File
@@ -1,94 +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
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
lightweightMode: Modo leve
copyEnv: Copiar variáveis de ambiente
confDir: Diretório de configuração
coreDir: Diretório do núcleo
logsDir: Diretório de logs
openDir: Abrir diretório
appLog: Log do aplicativo
coreLog: Log do núcleo
restartClash: Reiniciar o núcleo do Clash
restartApp: Reiniciar o aplicativo
vergeVersion: Versão do Verge
more: Mais
exit: Sair
tooltip:
systemProxy: Proxy do sistema
tun: TUN
profile: Perfil
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Скорый выход
body: Clash Verge скоро завершит работу.
appHidden:
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
updateReady:
title: Обновление Clash Verge
body: Новая версия (v{version}) скачана и готова к установке.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
core:
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
@@ -70,8 +71,6 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
@@ -92,3 +91,14 @@ tray:
systemProxy: Системный прокси
tun: TUN
profile: Профиль
widget:
unavailable: Откройте Clash Verge
failed: Ошибка обновления; проверьте Clash Verge
'on': Вкл.
'off': Выкл.
unknown: Неизвестно
name: Маршрутизация Clash Verge
description: Управление режимом маршрутизации, TUN и системным прокси.
update: Обновить Clash Verge
setting: Настройка
value: Значение
+22 -12
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
'on': System proxy has been enabled.
'off': System proxy has been disabled.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
@@ -18,8 +18,8 @@ notifications:
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -29,16 +29,17 @@ notifications:
appQuit:
title: Çıkış Yapılmak Üzere
body: Clash Verge kapanmak üzere.
appHidden:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
core:
alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.'
launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
@@ -70,8 +71,6 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
@@ -92,3 +91,14 @@ tray:
systemProxy: Sistem Vekil'i
tun: TUN
profile: Profil
widget:
unavailable: Clash Verge uygulamasını açın
failed: Güncelleme başarısız; Clash Verge uygulamasını kontrol edin
'on': Açık
'off': Kapalı
unknown: Bilinmiyor
name: Clash Verge Yönlendirme
description: Yönlendirme modu, TUN ve sistem vekili denetimleri.
update: Clash Verge güncelle
setting: Ayar
value: Değer
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: Чыгар алдыннан
body: Clash Verge чыгарга җыена.
appHidden:
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
core:
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
@@ -70,8 +71,6 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
@@ -92,3 +91,14 @@ tray:
systemProxy: Системалы прокси
tun: TUN
profile: Профиль
widget:
unavailable: Clash Verge ачыгыз
failed: Яңарту уңышсыз; Clash Verge тикшерегез
'on': Кабызылган
'off': Сүндерелгән
unknown: Билгесез
name: Clash Verge маршрутлау
description: Маршрутлау режимы, TUN һәм система проксие белән идарә итү.
update: Clash Verge яңарту
setting: Көйләү
value: Кыйммәт
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即将退出
body: Clash Verge 即将退出。
appHidden:
title: 应用已隐藏
body: Clash Verge 正在后台运行。
updateReady:
title: Clash Verge 更新
body: 新版本 (v{version}) 已下载完成,是否立即安装?
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
core:
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
launchGuard:
currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
@@ -70,8 +71,6 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
@@ -92,3 +91,14 @@ tray:
systemProxy: 系统代理
tun: TUN
profile: 订阅
widget:
unavailable: 请打开 Clash Verge
failed: 更新失败,请检查 Clash Verge
'on': 开启
'off': 关闭
unknown: 未知
name: Clash Verge 路由
description: 路由模式、TUN 和系统代理控制。
update: 更新 Clash Verge
setting: 设置
value: 值
+14 -4
View File
@@ -29,6 +29,9 @@ notifications:
appQuit:
title: 即將退出
body: Clash Verge 即將退出。
appHidden:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
updateReady:
title: Clash Verge 更新
body: 新版本(v{version})已下載完成,是否立即安裝?
@@ -37,8 +40,6 @@ notifications:
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
core:
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
launchGuard:
currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
@@ -70,8 +71,6 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
@@ -92,3 +91,14 @@ tray:
systemProxy: 系統代理
tun: 虛擬網路介面卡
profile: 訂閱
widget:
unavailable: 請開啟 Clash Verge
failed: 更新失敗,請檢查 Clash Verge
'on': 開啟
'off': 關閉
unknown: 未知
name: Clash Verge 路由
description: 路由模式、TUN 和系統代理控制。
update: 更新 Clash Verge
setting: 設定
value: 值
@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
clash-verge-logging = { workspace = true }
chrono = { workspace = true }
log = { workspace = true }
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::utils::{country_code_to_emoji, get_local_date_string};
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnlockItem {
pub name: String,
pub status: String,
+31 -23
View File
@@ -1,15 +1,3 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
@@ -17,13 +5,20 @@ use std::sync::{
use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
/// Serializes signal-triggered cleanups: a signal arriving while a quit
/// attempt is still in flight is dropped instead of overlapping it.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
@@ -41,18 +36,17 @@ impl ShutdownLatch {
.is_ok()
}
fn finish(&self) {
self.is_cleaning_up.store(false, Ordering::Release);
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
}
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -67,22 +61,36 @@ where
}
});
#[cfg(unix)]
unix::register(f);
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::ShutdownLatch;
use super::{ShutdownLatch, ShutdownOutcome};
#[test]
fn finished_attempt_releases_latch_for_the_next_signal() {
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish();
latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+3 -4
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -60,8 +60,7 @@ where
}
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish();
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
+83
View File
@@ -0,0 +1,83 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
+3
View File
@@ -0,0 +1,3 @@
# Xcode per-user state and local build output
xcuserdata/
DerivedData/
+19
View File
@@ -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
+232
View File
@@ -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;
}
@@ -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
}
+57
View File
@@ -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;
}
+219
View File
@@ -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:#}");
}
});
}
@@ -5,7 +5,6 @@ edition = "2024"
rust-version = "1.91"
[dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true }
@@ -15,7 +14,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.190"
libc = "0.2.189"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
@@ -1,46 +1,53 @@
use parking_lot::RwLock;
use serde::Serialize;
use tauri::{AppHandle, Runtime, State, command};
use tauri_plugin_clipboard_manager::ClipboardExt as _;
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
use crate::Platform;
#[derive(Serialize, specta::Type)]
#[derive(Serialize)]
pub struct SystemInfo {
pub system_name: String,
pub system_version: String,
pub system_kernel_version: String,
pub system_arch: String,
pub app_version: String,
pub app_core_mode: String,
pub app_is_admin: bool,
}
impl From<&Platform> for SystemInfo {
fn from(platform: &Platform) -> Self {
impl From<Platform> for SystemInfo {
fn from(platform: Platform) -> Self {
Self {
system_name: platform.sysinfo.system_name.clone(),
system_version: platform.sysinfo.system_version.clone(),
system_name: platform.sysinfo.system_name,
system_version: platform.sysinfo.system_version,
system_kernel_version: platform.sysinfo.system_kernel_version,
system_arch: platform.sysinfo.system_arch,
app_version: platform.appinfo.app_version,
app_core_mode: platform.appinfo.app_core_mode,
app_is_admin: platform.appinfo.app_is_admin,
}
}
}
#[command]
#[specta::specta]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
let platform = state.inner().read();
Ok(SystemInfo::from(&*platform))
Ok(SystemInfo::from(platform.clone()))
}
/// 获取应用的运行时间(毫秒)
#[command]
#[specta::specta]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> {
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
}
#[command]
#[specta::specta]
pub fn export_diagnostic_info<R: Runtime>(
app_handle: AppHandle<R>,
state: State<'_, RwLock<Platform>>,
) -> Result<(), String> {
) -> Result<(), Error> {
let info = state.inner().read().to_string();
let clipboard = app_handle.clipboard();
clipboard.write_text(info).map_err(|error| error.to_string())
clipboard.write_text(info)
}
-266
View File
@@ -1,269 +1,3 @@
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
## v2.5.7
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS**
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
</details>
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🍎 macOS**
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
## v2.5.5
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
</details>
## v2.5.4
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
### 🐞 修复问题
+6 -7
View File
@@ -11,14 +11,13 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
<p align="center">
Languages:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Preview
@@ -52,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
## Promotion
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### **Core Advantages:**
@@ -68,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
- 🎬 Unlocks **streaming platforms and mainstream AI services**
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
<p align="center">
Idiomas:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Vista previa
@@ -52,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
## Promociones
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
زبان‌ها:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## پیش‌نمایش
@@ -51,17 +50,17 @@
## تبلیغات
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
- بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K
- اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
言語:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## プレビュー
@@ -52,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
## プロモーション
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
+6 -7
View File
@@ -11,14 +11,13 @@
<p align="center">
언어:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## 미리보기
@@ -52,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
## 프로모션
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
-125
View File
@@ -1,125 +0,0 @@
<h1 align="center">
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
<br>
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
<br>
</h1>
<h3 align="center">
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
</h3>
<p align="center">
Idiomas:
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Pré-visualização
| Escuro | Claro |
| -------------------------------------- | -------------------------------------- |
| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) |
## Instalação
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
#### Como escolher o canal de lançamento
| Canal | Descrição | Link |
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
#### Guias de instalação e perguntas frequentes
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
### Canal do Telegram
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
---
## Promoções
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
- Desbloqueia serviços de streaming e acesso ao ChatGPT
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
---
## Funcionalidades
- Baseado em Rust de alto desempenho e no framework Tauri 2
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
- Editores visuais para nós e regras
- Backup e sincronização via WebDAV
### Perguntas frequentes
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
### Doações
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
## Desenvolvimento
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
```shell
pnpm i
pnpm run prebuild
pnpm dev
```
## Contribuições
Issues e pull requests são bem-vindos.
## Agradecimentos
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
## Licença
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
+6 -7
View File
@@ -11,14 +11,13 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
<p align="center">
Языки:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
<a href="./README_fa.md">فارسی</a>
</p>
## Предпросмотр
@@ -48,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+2 -116
View File
@@ -12,92 +12,6 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
import tseslint from 'typescript-eslint'
const ipcBoundaryRules = ({
allowCommands = false,
allowInvoke = false,
allowEvents = false,
} = {}) => {
const paths = [
...(!allowInvoke
? [
{
name: '@tauri-apps/api/core',
importNames: ['invoke'],
allowTypeImports: true,
message: 'Invoke backend commands through services/cmds.',
},
]
: []),
...(!allowEvents
? [
{
name: '@tauri-apps/api/event',
importNames: ['listen', 'emit', 'emitTo', 'once'],
allowTypeImports: true,
message:
'The event bus owns Tauri event subscriptions and emissions.',
},
]
: []),
]
const patterns = allowCommands
? []
: [
{
regex: '(^|/)bindings(\\.ts)?$',
importNames: ['commands'],
allowTypeImports: true,
message:
'Only services/cmds may import generated command implementations.',
},
]
const modules = [
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
]
const syntax = modules.flatMap((name) => [
{
selector: `ImportExpression[source.value='${name}']`,
message:
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
},
{
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
message:
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
},
{
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
message: 'Use the owned IPC service boundary for Tauri APIs.',
},
])
if (!allowCommands) {
syntax.push(
{
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
)
}
return {
'no-restricted-imports': ['error', { paths, patterns }],
'no-restricted-syntax': ['error', ...syntax],
}
}
export default defineConfig([
pluginESx.configs['flat/restrict-to-es2022'],
{
@@ -129,6 +43,7 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'crates/clash-verge-widget/build/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
@@ -229,7 +144,7 @@ export default defineConfig([
},
},
{
files: ['scripts/*.mjs'],
files: ['scripts/*.mjs', 'crates/clash-verge-widget/build/*.mjs'],
languageOptions: {
globals: {
@@ -248,33 +163,4 @@ export default defineConfig([
},
},
},
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
},
{
files: ['src/services/cmds.ts'],
rules: ipcBoundaryRules({ allowCommands: true }),
},
{
files: ['src/services/bindings.ts'],
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
},
{
files: ['src/services/bus/**/*.{ts,tsx}'],
rules: ipcBoundaryRules({ allowEvents: true }),
},
{
files: [
'src/components/proxy/proxy-chain.tsx',
'src/components/proxy/proxy-groups-chain.tsx',
'src/pages/proxies.tsx',
],
rules: {
'@typescript-eslint/no-floating-promises': [
'error',
{ ignoreVoid: false },
],
},
},
])
+2 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"entry": ["src/polyfills/*.js!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
@@ -17,8 +17,7 @@
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"],
"src/services/bindings.ts": ["types"]
"src/types/generated/**": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
+60 -62
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.9",
"version": "2.5.4",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -10,8 +10,8 @@
"dev:diff": "node scripts/dev.mjs dev",
"dev:trace": "node scripts/dev.mjs trace",
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"build": "node scripts/build.mjs",
"build:fast": "node scripts/build.mjs -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
@@ -30,8 +30,6 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
@@ -51,90 +49,90 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.13.1",
"ahooks": "^3.10.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.5.9",
"@tauri-apps/plugin-opener": "2.5.4",
"@tauri-apps/plugin-updater": "2.10.1",
"ahooks": "^3.9.7",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
"dayjs": "1.11.23",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"dayjs": "1.11.21",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31",
"monaco-editor": "^0.57.0",
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.2",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.11",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"react-router": "^8.0.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"swr": "^2.4.2",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.1",
"@tauri-apps/cli": "2.11.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "^24.12.4",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"@wdio/tauri-service": "1.3.0",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.11.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-es-x": "^10.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^10.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"knip": "6.32.1",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"ws": "8.22.0"
"vitest": "^5.0.0",
"webdriverio": "9.30.0",
"ws": "8.21.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -146,5 +144,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.11.2"
"packageManager": "pnpm@12.4.1"
}
+1791 -1547
View File
File diff suppressed because it is too large Load Diff
+1 -17
View File
@@ -56,12 +56,6 @@
"matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false
},
{
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
"matchFileNames": [".github/workflows/**"],
"matchPackageNames": ["/gh-aw/"],
"enabled": false
},
{
"description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"],
@@ -84,7 +78,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm"],
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"groupName": "pnpm"
},
{
@@ -137,16 +131,6 @@
],
"depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"description": "Update pnpm in devcontainer.json",
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
"matchStrings": [
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "pnpm",
"datasourceTemplate": "npm"
}
],
"postUpdateOptions": ["pnpmDedupe"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.0"
channel = "1.98.1"
components = ["rustfmt", "clippy"]
+62
View File
@@ -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()
-34
View File
@@ -1,34 +0,0 @@
import path from 'node:path'
import { ESLint } from 'eslint'
const ROOT = path.resolve(import.meta.dirname, '..')
const gates = process.argv.slice(2)
if (
!gates.length ||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
const results = await eslint.lintFiles(['src'])
const boundaryRules = new Set([
'no-restricted-imports',
'no-restricted-syntax',
'@typescript-eslint/no-floating-promises',
])
const violations = results.flatMap((result) =>
result.messages
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
.map((message) => ({ file: result.filePath, ...message })),
)
for (const violation of violations) {
console.error(
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
)
}
if (violations.length) process.exitCode = 1
else console.log('[arch] no violations')
-69
View File
@@ -1,69 +0,0 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const git = (...args) =>
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
const { contract } = JSON.parse(
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
)
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
const args = process.argv.slice(2)
if (args.length !== 2 || args[0] !== '--push') {
console.error('usage: check-contract-changes.mjs --push <remote>')
process.exit(2)
}
const hasChanges = (...range) =>
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
const assertCleanInputs = (...range) => {
if (
hasChanges(...range) ||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
) {
throw new Error(
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
)
}
}
let relevant = false
const head = git('rev-parse', 'HEAD')
for (const line of fs
.readFileSync(0, 'utf8')
.trim()
.split('\n')
.filter(Boolean)) {
const fields = line.trim().split(/\s+/)
if (
fields.length !== 4 ||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
) {
throw new Error('Invalid pre-push ref update')
}
const [, local, , remote] = fields
if (/^0+$/.test(local) || local === remote) continue
// A new remote branch has no upstream; use its fork point with the remote default branch.
const base = /^0+$/.test(remote)
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
: remote
if (!hasChanges(base, local)) continue
const commit = git('rev-parse', `${local}^{commit}`)
if (commit !== head)
throw new Error(
'Check out the pushed commit before validating its changed IPC contract.',
)
assertCleanInputs('HEAD')
relevant = true
}
if (relevant) {
execFileSync(
process.execPath,
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
{
cwd: ROOT,
stdio: 'inherit',
},
)
} else {
console.log('[contract] inputs unchanged; skipping Rust generation')
}
+1 -1
View File
@@ -49,7 +49,7 @@ const FRONTEND_EXTENSIONS = new Set([
'.vue',
'.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'])
-23
View File
@@ -1,23 +0,0 @@
{
"contract": [
"src-tauri/**/*.rs",
"crates/**/*.rs",
"Cargo.toml",
"Cargo.lock",
"src-tauri/Cargo.toml",
"crates/**/Cargo.toml",
"rust-toolchain.toml",
"src-tauri/tauri.conf*.json",
"src/services/bindings.ts",
"scripts/gen-contract.mjs",
"scripts/check-contract-changes.mjs",
"biome.json",
"biome.jsonc",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/frontend-check.yml",
".husky/pre-push"
]
}
+141 -85
View File
@@ -1,23 +1,36 @@
import { execFileSync, spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
export const developmentServiceWatchPaths = [
'src',
'resources',
'Cargo.toml',
'Cargo.lock',
].map((name) => join(serviceRepository, name))
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
@@ -26,15 +39,10 @@ function run(command, args, options = {}) {
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.once('error', reject)
child.once('close', (code, signal) => {
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise(stdout)
resolvePromise()
return
}
const error = new Error(
@@ -46,86 +54,134 @@ function run(command, args, options = {}) {
})
}
export async function prepareDevelopmentService() {
await access(serviceManifest)
const output = await run(
'cargo',
[
'build',
'--manifest-path',
serviceManifest,
'--target-dir',
process.env.CARGO_TARGET_DIR ||
join(serviceRepository, 'target', 'development'),
'--features',
'standalone,client,development-channel',
'--bins',
'--message-format=json-render-diagnostics',
],
{ stdio: ['inherit', 'pipe', 'inherit'] },
)
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
const artifacts = new Map()
for (const line of output.split(/\r?\n/).filter(Boolean)) {
const artifact = JSON.parse(line)
if (
artifact.reason === 'compiler-artifact' &&
resolve(artifact.manifest_path) === serviceManifest &&
artifact.target.kind.includes('bin') &&
artifact.executable
) {
artifacts.set(artifact.target.name, resolve(artifact.executable))
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
const executables = [
'clash-verge-service',
'clash-verge-service-install',
'clash-verge-service-uninstall',
'service-integration-driver',
].map((name) => {
const executable = artifacts.get(name)
if (!executable)
throw new Error(`Cargo did not report the ${name} executable`)
return executable
})
const serviceDirectory = dirname(executables[0])
for (const executable of executables) {
if (dirname(executable) !== serviceDirectory) {
throw new Error(
'Development service tools were built in different directories',
)
}
await access(executable)
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
function windowsElevationInvocation(installer, environment = process.env) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
return serviceDirectory
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService()
const extension = platform === 'win32' ? '.exe' : ''
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
`clash-verge-service-install${extension}`,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
/^host: (.+)$/m,
)?.[1]
if (!host) throw new Error('rustc did not report its host target')
const args = ['--prepare-install', '--ensure']
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
args.push(
'--core',
`${name}${extension}`,
join(
repositoryRoot,
'src-tauri',
'sidecar',
`${name}-${host}${extension}`,
),
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
await run(installer, args)
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
-4
View File
@@ -10,7 +10,6 @@ import {
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
developmentServiceWatchPaths,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
@@ -32,9 +31,6 @@ export function buildTauriInvocation(
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
for (const path of developmentServiceWatchPaths) {
args.push('--additional-watch-folders', path)
}
return {
command: 'pnpm',
args,
-55
View File
@@ -1,55 +0,0 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
const args = process.argv.slice(2)
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
console.error('usage: gen-contract.mjs [--check]')
process.exit(2)
}
const check = args[0] === '--check'
const temporary = check
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
: null
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
try {
execFileSync(
'cargo',
[
'run',
'-p',
'clash-verge',
'--example',
'frontend-contract',
'--locked',
'-q',
...(check ? ['--', output] : []),
],
{ cwd: ROOT, stdio: 'inherit' },
)
const formatted = execFileSync(
process.execPath,
[
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
'format',
'--stdin-file-path',
BINDINGS,
],
{ cwd: ROOT, input: fs.readFileSync(output) },
)
if (check) {
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
console.error(
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
)
process.exitCode = 1
}
} else {
fs.writeFileSync(BINDINGS, formatted)
}
} finally {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
}
+11 -53
View File
@@ -1,9 +1,9 @@
import { execFileSync, execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import zlib from 'node:zlib'
import { execFileSync, execSync } from 'child_process'
import { createHash } from 'crypto'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import zlib from 'zlib'
import AdmZip from 'adm-zip'
import { glob } from 'glob'
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
const zip = new AdmZip(tempZip)
zip
.getEntries()
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
zip.extractAllTo(tempDir, true)
if (fs.existsSync(tempDll)) {
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
@@ -586,41 +586,6 @@ async function resolveServiceBundle() {
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const serviceDependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
if (sourcePath) {
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
execFileSync(
'cargo',
[
'build',
'--manifest-path',
manifest,
'--target-dir',
targetDirectory,
'--target',
SIDECAR_HOST,
'--release',
'--features',
'standalone,client',
'--bins',
],
{ stdio: 'inherit' },
)
await fsp.mkdir(SERVICE_DIR, { recursive: true })
for (const { sourceFile, targetPath } of files) {
await fsp.copyFile(
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
targetPath,
)
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
await updateHashCache(targetPath)
}
return
}
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
@@ -639,9 +604,8 @@ async function resolveServiceBundle() {
const zip = new AdmZip(tempArchive)
zip
.getEntries()
.forEach(
(entry) =>
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
.forEach((entry) =>
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
)
zip.extractAllTo(tempDir, true)
} else {
@@ -688,7 +652,7 @@ async function resolveCoreHashes() {
.digest('hex')
lines.push(`!define ${define} "${digest}"`)
}
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
log_success(`Generated ${CORE_HASHES_NSH}`)
}
@@ -697,11 +661,6 @@ const resolveMmdb = () =>
file: 'Country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
})
const resolveASNMmdb = () =>
resolveResource({
file: 'ASN.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
})
const resolveGeosite = () =>
resolveResource({
file: 'geosite.dat',
@@ -747,7 +706,6 @@ const tasks = [
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
{ name: 'service', func: resolveServiceBundle, retry: 5 },
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
{
+15 -50
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,20 +108,17 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
const versionWithoutV = newVersion.startsWith('v')
packageJson.version = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
)
if (updatedData === data) {
throw new Error('version field was not found in package.json')
}
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating package.json version:', error)
throw error
@@ -156,34 +153,6 @@ async function updateCargoVersion(newVersion) {
}
}
async function updateCargoLockVersion(newVersion) {
const _dirname = process.cwd()
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const packageVersionPattern =
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
try {
const data = await fs.readFile(cargoLockPath, 'utf8')
const updatedData = data.replace(
packageVersionPattern,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('clash-verge package entry was not found in Cargo.lock')
}
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating Cargo.lock version:', error)
throw error
}
}
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -199,16 +168,13 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
)
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
)
@@ -273,7 +239,6 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
+19 -41
View File
@@ -30,51 +30,29 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
original_dns=$(networksetup -getdnsservers "$hardware_port")
backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
if [ -f "$backup" ]; then
rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
is_valid_dns=true
break
fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1
networksetup -setdnsservers "$hardware_port" "$1"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then
echo "empty" >"$backup" || exit 1
fi
networksetup -setdnsservers "$hardware_port" "$1"
+8 -57
View File
@@ -1,62 +1,13 @@
#!/bin/bash
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
result=0
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
original_dns=$(cat .original_dns.txt)
networksetup -setdnsservers "$hardware_port" $original_dns
rm -rf .original_dns.txt
fi
for backup in .original_dns/*; do
[ -f "$backup" ] || continue
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1
continue
fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then
original_dns=$(cat "$backup") || { result=1; continue; }
# Older releases could back up the placeholder itself.
[ "$original_dns" = "$1" ] && original_dns=empty
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
rm -f "$backup" || result=1
done
rmdir .original_dns 2>/dev/null
exit $result
+12 -3
View File
@@ -14,13 +14,22 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const tag = { name: latestRelease.tag_name }
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
})
const tag = tags.find((t) => t.name.startsWith('v'))
console.log(tag)
console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md
+2 -3
View File
@@ -40,11 +40,10 @@ async function resolveUpdater() {
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
const stableTagRegex = /^v\d+\.\d+\.\d+$/
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const stableTag = { name: latestRelease.tag_name }
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log('All tags:', tags.map((t) => t.name).join(', '))
-24
View File
@@ -1,24 +0,0 @@
# Backend Architecture Guardrails
These rules supplement the repository's root `AGENTS.md`.
- **Configuration ownership.** `config` owns source configuration, drafts and
persistence. The effects layer owns patch orchestration. Rollbackable
transactions publish state after persistence and commit; already applied
runtime and independently committed state remain authoritative even when a
later step fails. Preserve that state, its notifications and the error.
- **Effect ownership.** The declarative registry classifies every Verge patch field,
including fields without effects and platform-specific fields. Effects
reconcile toward the requested state with explicit dependencies and ordering.
Configuration rollback does not imply reversal of OS or lifecycle effects.
- **Shared wire contract.** Notifications are typed. Event names, status values
and payload shapes stay compatible with frontend producers and consumers.
- **Synchronization ownership.** Configuration-write serialization precedes
configuration-update ownership, which precedes Core lifecycle ownership.
When both are held, the lifecycle lock precedes the DNS lock. Independent
transactions retain their ownership boundaries; asynchronous work does not
hold synchronous data guards across suspension.
- **Retry ownership.** Bounded retries share backend policy while preserving
attempts, delays and terminal errors. Background monitors own their lifetime
and cancellation separately; waiting for a condition or traversing distinct
candidates is not a retry policy.
+32 -35
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.9"
version = "2.5.4"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
default-run = "clash-verge"
build = "build.rs"
edition = "2024"
rust-version = "1.99"
rust-version = "1.95"
[lib]
name = "app_lib"
@@ -29,12 +29,13 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.7.1", features = [] }
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -58,13 +59,14 @@ smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
socket2 = { workspace = true }
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"] }
dirs = "7.0.0"
dunce = "1.0.5"
nanoid = "0.5"
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"
reqwest = { workspace = true }
regex = { workspace = true }
@@ -72,56 +74,51 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.1"
flate2 = "1.1.10"
tauri-plugin-shell = "2.3.5"
tauri-plugin-opener = "2.5.4"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-fs = "2.5.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-window-state = "2.4.1"
zip = "8.6.0"
flate2 = "1.1.9"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
aes-gcm = { version = "0.11.0" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.5.1"
tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.0"
tauri-plugin-http = "2.5.9"
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" }
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",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.45", features = ["ring"] }
tokio-rustls = "0.26.4"
rustls = { version = "0.23.43", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5"
clash-verge-widget = { workspace = true }
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true }
core-foundation = "0.9"
system-configuration = "0.7"
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
"NSDictionary",
@@ -152,9 +149,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.13.1"
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.10.1"
[dev-dependencies]
criterion = { workspace = true }
-14
View File
@@ -1,14 +0,0 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1);
let output = args
.next()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
if args.next().is_some() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
}
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
Ok(())
}
@@ -1,38 +0,0 @@
#!/bin/bash
set -e
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
fi
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
exit 0
fi
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
for service in clash-verge-service clash-verge-service-dev; do
for directory in bin cores; do
target="/var/lib/$service/$directory"
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
echo "Refusing to relabel a symlinked service directory: $target" >&2
exit 1
fi
spec="$target(/.*)?"
# Older semanage versions reject -a when an exact rule already exists.
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
printf '%s\n' "$add_error" >&2
exit 1
fi
fi
if [ -d "$target" ]; then
/usr/sbin/restorecon -R "$target"
fi
done
done
-1
View File
@@ -901,7 +901,6 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
+7 -49
View File
@@ -1,22 +1,15 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent},
feat,
utils::dirs,
};
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel};
use tauri::{AppHandle, Manager as _};
#[tauri::command]
#[specta::specta]
pub async fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir().stringify_err()?;
open::that(app_dir).stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn open_core_dir() -> CmdResult<()> {
let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
@@ -24,14 +17,12 @@ pub async fn open_core_dir() -> CmdResult<()> {
}
#[tauri::command]
#[specta::specta]
pub async fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir().stringify_err()?;
open::that(log_dir).stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() {
@@ -43,61 +34,28 @@ pub fn open_devtools(app_handle: AppHandle) {
}
#[tauri::command]
#[specta::specta]
pub async fn exit_app() {
feat::quit().await;
}
#[tauri::command]
#[specta::specta]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app().await;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn install_update(
app_handle: AppHandle,
version: std::string::String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
let version: String = version.into();
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
})
.await
.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn cancel_update_download() {
SilentUpdater::global().cancel_download();
}
#[tauri::command]
#[specta::specta]
pub fn get_app_dir() -> CmdResult<std::string::String> {
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir)
}
#[tauri::command]
#[specta::specta]
pub async fn download_icon_cache(
url: std::string::String,
name: std::string::String,
) -> CmdResult<std::string::String> {
let url: String = url.into();
let name: String = name.into();
feat::download_icon_cache(url, name).await.map(Into::into)
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
feat::download_icon_cache(url, name).await
}
#[tauri::command]
#[specta::specta]
pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
let path: String = path.into();
feat::copy_icon_file(path, icon_info).await.map(Into::into)
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
feat::copy_icon_file(path, icon_info).await
}
+5 -16
View File
@@ -4,44 +4,33 @@ use feat::LocalBackupFile;
use smartstring::alias::String;
#[tauri::command]
#[specta::specta]
pub async fn create_local_backup() -> CmdResult<()> {
feat::create_local_backup().await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
feat::list_local_backup().await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
feat::delete_local_backup(filename).await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
feat::restore_local_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
}
#[tauri::command]
#[specta::specta]
pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
let source: String = source.into();
feat::import_local_backup(source).await.map(Into::into).stringify_err()
pub async fn import_local_backup(source: String) -> CmdResult<String> {
feat::import_local_backup(source).await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
let destination: String = destination.into();
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
feat::export_local_backup(filename, destination).await.stringify_err()
}
+31 -59
View File
@@ -1,5 +1,4 @@
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
use crate::core::notify::NoticeStatus;
use crate::feat;
use crate::utils::{dirs, yaml_emitter};
use crate::{
@@ -12,33 +11,30 @@ use crate::{
},
};
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use tokio::fs;
#[tauri::command]
#[specta::specta]
pub async fn copy_clash_env() -> CmdResult {
feat::copy_clash_env().await.stringify_err()
feat::copy_clash_env().await;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().await.data_arc().get_client_info())
}
#[tauri::command]
#[specta::specta]
pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
feat::patch_clash(&payload.0)
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload)
.await
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
}
#[tauri::command]
#[specta::specta]
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
let payload: String = payload.into();
pub async fn patch_clash_mode(payload: String) -> CmdResult {
feat::change_clash_mode(payload)
.await
.with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -46,15 +42,12 @@ pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command]
#[specta::specta]
pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
Ok(Config::clash().await.data_arc().get_mode())
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
}
#[tauri::command]
#[specta::specta]
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
let clash_core: String = clash_core.into();
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await {
@@ -63,14 +56,14 @@ pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Opt
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
handle::Handle::notice_message("config_core::change_success", clash_core);
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let failed = err.context("core changed but failed to restart");
let error_msg: String = format!("{failed:#}").into();
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
}
@@ -79,14 +72,13 @@ pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Opt
Err(err) => {
let error_msg: String = format!("{err:#}").into();
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
handle::Handle::notice_message("config_core::change_error", error_msg);
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
}
}
}
#[tauri::command]
#[specta::specta]
pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await);
let result = CoreManager::global()
@@ -101,7 +93,6 @@ pub async fn restart_core() -> CmdResult {
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
#[tauri::command]
#[specta::specta]
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
if report.upgraded {
@@ -111,9 +102,7 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
}
#[tauri::command]
#[specta::specta]
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
let url: String = url.into();
pub async fn test_delay(url: String) -> CmdResult<u32> {
let result = match feat::test_delay(url).await {
Ok(delay) => delay,
Err(e) => {
@@ -125,14 +114,13 @@ pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
}
#[tauri::command]
#[specta::specta]
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
use crate::utils::dirs;
use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
@@ -140,54 +128,42 @@ pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub fn take_dns_override_notice() -> bool {
crate::config::dns::take_dns_override_notice()
}
#[tauri::command]
#[specta::specta]
pub async fn set_dns_override(
profile_uid: std::string::String,
enabled: bool,
confirmation: Option<std::string::String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid.into();
let confirmation = confirmation.map(String::from);
feat::set_dns_override(profile_uid, enabled, confirmation)
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
}
#[tauri::command]
#[specta::specta]
pub async fn apply_dns_config(apply: bool) -> CmdResult {
if apply {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
logging!(warn, Type::Config, "DNS config file not found");
return Err("DNS config file not found".into());
}
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to read DNS config {}: {e}",
dns_path.display()
);
logging!(error, Type::Config, "Failed to read DNS config: {e}");
})?;
serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(
error,
Type::Config,
"Failed to parse DNS config {}: {e}",
dns_path.display()
);
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
})?;
let mut patch = serde_yaml_ng::Mapping::new();
patch.insert("dns".into(), patch_config.into());
Config::runtime().await.edit_draft(|d| {
d.patch_config(&patch);
});
CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error)
@@ -211,22 +187,20 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
pub async fn get_dns_config_content() -> CmdResult<Option<String>> {
use crate::utils::dirs;
use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
match fs::read_to_string(&dns_path).await {
Ok(content) => Ok(Some(content)),
Ok(content) => Ok(Some(content.into())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(CommandFailure::plain(error)),
}
}
#[tauri::command]
#[specta::specta]
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
let app_dir = dirs::app_home_dir().stringify_err()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
@@ -236,14 +210,12 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
.await
.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs)
+1 -2
View File
@@ -1,9 +1,8 @@
use crate::core::lightweight;
use crate::module::lightweight;
use super::CmdResult;
#[tauri::command]
#[specta::specta]
pub async fn entry_lightweight_mode() -> CmdResult {
lightweight::entry_lightweight_mode().await;
Ok(())
-2
View File
@@ -5,13 +5,11 @@ use crate::{
};
#[tauri::command]
#[specta::specta]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings)
.await
@@ -4,7 +4,6 @@ use std::time::Duration;
use tauri::{command, ipc::Channel};
#[command]
#[specta::specta]
pub fn get_unlock_items() -> Vec<UnlockItem> {
clash_verge_media_unlock::default_unlock_items()
}
@@ -27,7 +26,6 @@ async fn create_client() -> Result<Client, String> {
}
#[command]
#[specta::specta]
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
let client = create_client().await?;
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
@@ -37,7 +35,6 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
}
#[command]
#[specta::specta]
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
let client = create_client().await?;
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
+1 -1
View File
@@ -4,7 +4,7 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
/// Structured command failure returned to the frontend.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct CommandFailure {
/// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")]
+20 -34
View File
@@ -3,12 +3,13 @@ use crate::cmd::StringifyErr as _;
use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
#[tauri::command]
#[specta::specta]
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
Sysopt::global().wait_idle().await;
// With no network service there is no proxy configured anywhere, which reads as disabled.
let sys_proxy = match Sysproxy::get_system_proxy() {
@@ -16,40 +17,40 @@ pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
other => other.stringify_err()?,
};
let Sysproxy {
host,
bypass,
port,
enable,
ref host,
ref bypass,
ref port,
ref enable,
} = sys_proxy;
Ok(SystemProxy {
enable,
server: format!("{host}:{port}"),
bypass,
})
let mut map = Mapping::new();
map.insert("enable".into(), (*enable).into());
map.insert("server".into(), format!("{}:{}", host, port).into());
map.insert("bypass".into(), bypass.as_str().into());
Ok(map)
}
#[tauri::command]
#[specta::specta]
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
Sysopt::global().wait_idle().await;
let auto_proxy = match Autoproxy::get_auto_proxy() {
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
other => other.stringify_err()?,
};
let Autoproxy { enable, url } = auto_proxy;
let Autoproxy { ref enable, ref url } = auto_proxy;
Ok(AutoProxy { enable, url })
let mut map = Mapping::new();
map.insert("enable".into(), (*enable).into());
map.insert("url".into(), url.as_str().into());
Ok(map)
}
#[tauri::command]
#[specta::specta]
pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn get_system_hostname() -> String {
match gethostname().into_string() {
Ok(name) => name,
@@ -61,14 +62,12 @@ pub fn get_system_hostname() -> String {
}
#[tauri::command]
#[specta::specta]
pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
}
#[tauri::command]
#[specta::specta]
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
let names = get_network_interfaces();
@@ -78,22 +77,9 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfa
for interface in interfaces {
if names.contains(&interface.name) {
result.push(interface.into());
result.push(interface);
}
}
Ok(result)
}
#[derive(serde::Serialize, specta::Type)]
pub struct SystemProxy {
pub enable: bool,
pub server: String,
pub bypass: String,
}
#[derive(serde::Serialize, specta::Type)]
pub struct AutoProxy {
pub enable: bool,
pub url: String,
}
+18 -116
View File
@@ -1,7 +1,6 @@
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles;
use crate::core::notify::NoticeStatus;
use crate::utils::window_manager::WindowManager;
use crate::{
config::{
@@ -33,7 +32,6 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
}
#[tauri::command]
#[specta::specta]
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
let draft = Config::profiles().await;
let data = draft.data_arc();
@@ -41,7 +39,6 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
}
#[tauri::command]
#[specta::specta]
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
match feat::enhance_profiles().await {
Ok(outcome) if outcome.is_valid() => {
@@ -67,7 +64,6 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it,
@@ -97,10 +93,7 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
}
#[tauri::command]
#[specta::specta]
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
let active_id: String = active_id.into();
let over_id: String = over_id.into();
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -120,16 +113,16 @@ pub async fn reorder_profile(active_id: std::string::String, over_id: std::strin
}
#[tauri::command]
#[specta::specta]
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
let item: PrfItem = item.into();
let file_data = file_data.map(String::from);
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => {
profiles_save_file_safe()
.await
.with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await);
if let Some(uid) = &item.uid {
handle::Handle::notify_profile_changed(uid);
}
Ok(())
}
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
@@ -137,9 +130,7 @@ pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::
}
#[tauri::command]
#[specta::specta]
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
let index: String = index.into();
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()),
Err(e) => {
@@ -150,9 +141,7 @@ pub async fn update_profile(index: std::string::String, option: Option<PrfOption
}
#[tauri::command]
#[specta::specta]
pub async fn delete_profile(index: std::string::String) -> CmdResult {
let index: String = index.into();
pub async fn delete_profile(index: String) -> CmdResult {
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await;
@@ -293,7 +282,7 @@ async fn handle_update_error<E: std::fmt::Display>(
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
discard_and_restore(current_profile).await?;
let message: String = e.to_string().into();
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
handle::Handle::notice_message("config_validate::boot_error", message.clone());
Ok(ValidationOutcome::invalid_from_message(message))
}
@@ -323,85 +312,9 @@ async fn perform_config_update(
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileSelection {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>,
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileCreate {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub option: Option<PrfOption>,
}
impl From<ProfileCreate> for PrfItem {
fn from(item: ProfileCreate) -> Self {
Self {
itype: item.itype,
name: item.name,
desc: item.desc,
url: item.url,
option: item.option,
..Self::default()
}
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfilePatch {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
}
impl From<ProfilePatch> for PrfItem {
fn from(patch: ProfilePatch) -> Self {
Self {
itype: patch.itype,
name: patch.name,
desc: patch.desc,
file: patch.file,
url: patch.url,
selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
..Self::default()
}
}
}
#[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
#[specta::specta]
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
if CURRENT_SWITCHING_PROFILE
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
@@ -411,17 +324,11 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles {
current: profiles.current,
items: None,
};
let target_profile = profiles.current.as_ref();
let previous_profile = Config::profiles().await.data_arc().current.clone();
Config::profiles().await.edit_draft(|d| {
d.patch_config(&profiles);
});
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
perform_config_update(target_profile, previous_profile.as_ref())
.await
@@ -429,17 +336,15 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
}
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
let profiles = ProfileSelection {
let profiles = IProfiles {
current: Some(profile_index),
items: None,
};
patch_profiles_config(profiles).await
}
#[tauri::command]
#[specta::specta]
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into();
let index: String = index.into();
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref()
@@ -479,8 +384,7 @@ pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) ->
}
#[tauri::command]
#[specta::specta]
pub async fn view_profile(index: std::string::String) -> CmdResult {
pub async fn view_profile(index: String) -> CmdResult {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
let file = profiles_ref
@@ -504,8 +408,7 @@ pub async fn view_profile(index: std::string::String) -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
pub async fn read_profile_file(index: String) -> CmdResult<String> {
let item = {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
@@ -525,7 +428,7 @@ pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::str
.join(file.as_str());
match tokio::fs::try_exists(&path).await {
Ok(true) => {}
Ok(false) => return Ok(std::string::String::new()),
Ok(false) => return Ok(String::new()),
Err(err) => {
return Err(coded_error(
"PROFILE_READ_FAILED",
@@ -536,12 +439,11 @@ pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::str
}
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data.into())
Ok(data)
}
#[tauri::command]
#[specta::specta]
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time)
+32 -76
View File
@@ -4,7 +4,7 @@ use crate::{
config::Config,
core::{
handle::Handle,
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
@@ -12,13 +12,12 @@ use crate::{
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::{
collections::{BTreeMap, HashSet},
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
};
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
#[specta::specta]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
.await
@@ -26,7 +25,6 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
}
#[tauri::command]
#[specta::specta]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
.await
@@ -52,93 +50,24 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.collect()
}
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
let Some(config) = config else {
return BTreeMap::new();
};
let mut all_providers = config
.get("proxy-providers")
.and_then(|providers| providers.as_mapping())
.into_iter()
.flat_map(|providers| providers.keys())
.filter_map(|name| name.as_str())
.map(str::to_owned)
.collect::<Vec<_>>();
all_providers.sort();
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.as_mapping())
.filter_map(|group| {
let name = group.get("name")?.as_str()?.to_owned();
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
all_providers.clone()
} else {
group
.get("use")
.and_then(|providers| providers.as_sequence())
.into_iter()
.flatten()
.filter_map(|provider| provider.as_str())
.map(str::to_owned)
.collect()
};
let exclude_types = group
.get("exclude-type")
.and_then(|types| types.as_str())
.filter(|types| !types.is_empty())
.map(|types| types.split('|').map(str::to_owned).collect())
.unwrap_or_default();
Some((
name,
GroupScope {
providers,
exclude_types,
},
))
})
.collect()
}
#[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
providers: providers.ok(),
}))
}
#[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -155,7 +84,6 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
}
async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop {
match Tray::global().update_menu().await {
Ok(_) => {
@@ -181,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()]
);
}
}

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