Compare commits

..
Author SHA1 Message Date
TungliesandGLM-5.3 010c037dde refactor: replace smartstring with tristr across the codebase
Co-authored-by: GLM-5.3 <glm@z.ai>
2026-10-27 06:55:10 +08:00
TungliesandGLM-5.3 e017d4d9d4 refactor: replace smartstring with tristr across the codebase
Co-authored-by: GLM-5.3 <glm@z.ai>
2026-10-27 06:47:53 +08:00
197 changed files with 3425 additions and 5498 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "26.13.0",
"pnpmVersion": "12.16.1"
"version": "24.22.1",
"pnpmVersion": "12.15.0"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1",
+58 -13
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -157,12 +157,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -182,9 +185,17 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -193,6 +204,12 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v1
env:
@@ -243,12 +260,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -262,9 +282,17 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -317,6 +345,12 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -370,18 +404,15 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -395,9 +426,17 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -413,6 +452,12 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows
id: build
uses: tauri-apps/tauri-action@v1
@@ -479,7 +524,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
-80
View File
@@ -1,80 +0,0 @@
name: Contract Parity
on:
pull_request:
paths:
- "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/contract-parity.yml"
- ".husky/pre-push"
workflow_dispatch:
permissions:
contents: read
env:
HUSKY: 0
jobs:
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
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
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
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Provision sidecar placeholders for tauri-build
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
run: pnpm gen:contract --check
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+11 -3
View File
@@ -77,12 +77,11 @@ jobs:
uses: dtolnay/rust-toolchain@1.99.1
- name: Rust Cache
if: github.event.inputs[matrix.input] == 'true'
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -104,9 +103,18 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
lookup-only: true
- name: Pnpm install and check
if: github.event.inputs[matrix.input] == 'true'
run: |
+112 -14
View File
@@ -2,23 +2,11 @@ name: Frontend Check
on:
pull_request:
paths:
- "src/**"
- "**/*.js"
- "**/*.ts"
- "**/*.tsx"
- "**/*.css"
- "**/*.scss"
- "**/*.json"
- "**/*.md"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "vite.config.*"
- ".github/workflows/frontend-check.yml"
workflow_dispatch:
permissions:
contents: read
pull-requests: read
env:
HUSKY: 0
@@ -29,32 +17,142 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check frontend changes
id: check_frontend
uses: dorny/paths-filter@v4
with:
filters: |
frontend:
- 'src/**'
- '**/*.js'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '**/*.json'
- '**/*.md'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'knip.json'
- 'eslint.config.ts'
- 'tsconfig.json'
- 'vite.config.*'
- name: Skip if no frontend changes
if: steps.check_frontend.outputs.frontend != 'true'
run: echo "No frontend changes, skipping frontend checks."
- name: Install pnpm
if: steps.check_frontend.outputs.frontend == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '26.13.0'
node-version: '24.22.1'
cache: 'pnpm'
- name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: |
pnpm-shared-stable-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
if: steps.check_frontend.outputs.frontend == 'true'
- name: Run Prettier check
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm format:check
- name: Run ESLint
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm lint
- name: Run TypeScript typecheck
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck
- name: Run tests
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm test
- 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.22.1'
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
+23 -9
View File
@@ -2,14 +2,6 @@ name: Clippy Lint
on:
pull_request:
paths:
- "src-tauri/**"
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/config.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch:
env:
HUSKY: 0
@@ -31,6 +23,28 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check src-tauri changes
if: github.event_name != 'workflow_dispatch'
id: check_changes
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- name: Skip if src-tauri not changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust != 'true'
run: echo "No src-tauri changes, skipping clippy lint."
- name: Continue if src-tauri changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust == 'true'
run: echo "src-tauri changed, running clippy lint."
- name: Manual trigger - always run
if: github.event_name == 'workflow_dispatch'
run: |
echo "Manual trigger detected: skipping changes check and running clippy."
- name: Checkout Repository
uses: actions/checkout@v7
@@ -65,7 +79,7 @@ jobs:
run: cargo clippy-all
- name: Run Windows security tests
if: matrix.os == 'windows-latest'
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }}
-79
View File
@@ -1,79 +0,0 @@
name: Prepare Release
on:
pull_request_target:
types: [edited, closed]
branches: [dev]
workflow_dispatch:
inputs:
channel:
description: Release channel
required: true
type: choice
options: [rc, stable]
default: rc
release_tag:
description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable'
required: false
type: string
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
jobs:
prepare:
if: >-
${{ github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
((startsWith(github.event.pull_request.head.ref, 'release/v') &&
((github.event.action == 'closed' && github.event.pull_request.merged == true) ||
(github.event.changes.body && github.event.pull_request.state == 'open' &&
contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) ||
(startsWith(github.event.pull_request.head.ref, 'next-dev/v') &&
github.event.changes.body && github.event.pull_request.state == 'open' &&
(contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') ||
contains(github.event.pull_request.body, '[x] Use the next minor version') !=
contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Require the dev branch
if: github.event_name == 'workflow_dispatch'
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then
echo '::error::Run Prepare Release from the dev branch.'
exit 1
fi
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
if: github.event.action != 'closed'
with:
run_install: false
- run: pnpm install --frozen-lockfile --ignore-scripts
if: github.event.action != 'closed'
- name: Create or refresh version or publication PR
env:
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_CHANNEL: ${{ inputs.channel }}
RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
+118 -141
View File
@@ -1,9 +1,6 @@
name: Release Build
on:
pull_request:
types: [edited]
branches: [main]
push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags:
@@ -14,59 +11,75 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: release-publication
cancel-in-progress: false
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
prepare_release:
name: Approve Snapshot and Prepare Draft Release
if: >-
${{ github.event_name == 'push' ||
(github.event.changes.body &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
startsWith(github.event.pull_request.head.ref, 'publish/v') &&
contains(github.event.pull_request.body, '[x] Publish this approved release')) }}
check_tag_version:
name: Check Release Tag and package.json Version Consistency
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
outputs:
release_tag: ${{ steps.source.outputs.release_tag }}
source_sha: ${{ steps.source.outputs.source_sha }}
channel: ${{ steps.source.outputs.channel }}
release_id: ${{ steps.draft.outputs.id }}
steps:
- name: Checkout trusted release tooling
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- name: Validate source and publish the approved snapshot
id: source
env:
RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
- name: Create or reuse draft release
id: draft
env:
RELEASE_TAG: ${{ steps.source.outputs.release_tag }}
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: node scripts-workflow/release.mjs prepare-draft
- name: Check if tag is from main branch
run: |
TAG_REF="${GITHUB_REF##*/}"
echo "Checking if tag $TAG_REF is from main branch..."
TAG_COMMIT=$(git rev-list -n 1 $TAG_REF)
MAIN_COMMITS=$(git rev-list origin/main)
if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then
echo "✅ Tag $TAG_REF is from main branch"
else
echo "❌ Tag $TAG_REF is not from main branch"
echo "This release workflow only accepts tags from main branch."
exit 1
fi
- name: Check tag and package.json version
run: |
TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
echo "Current tag: $TAG_REF"
PKG_VERSION=$(jq -r .version package.json)
echo "package.json version: $PKG_VERSION"
EXPECTED_TAG="v$PKG_VERSION"
if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then
echo "❌ Version mismatch:"
echo " Git tag : $TAG_REF"
echo " package.json : $EXPECTED_TAG"
exit 1
fi
echo "✅ Tag and package.json version are consistent."
prepare_release:
name: Prepare Draft Release
runs-on: ubuntu-latest
needs: [check_tag_version]
outputs:
release_id: ${{ steps.prepare_release.outputs.id }}
steps:
- name: Create or update draft release
id: prepare_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
name: 'Clash Verge Rev ${{ github.ref_name }}'
body: 'Draft release, will be updated later.'
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
update_tag:
name: Publish Completed Release
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
outputs:
source_sha: ${{ needs.prepare_release.outputs.source_sha }}
channel: ${{ needs.prepare_release.outputs.channel }}
name: Update tag
runs-on: ubuntu-latest
needs:
[
@@ -78,8 +91,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -92,7 +103,7 @@ jobs:
- name: Set Env
run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
TAG_REF="$RELEASE_TAG"
TAG_REF="${GITHUB_REF##*/}"
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -138,16 +149,10 @@ jobs:
Created at ${{ env.BUILDTIME }}.
EOF
- name: Verify release tag still points to the approved source
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
run: node scripts-workflow/release.mjs verify-target
- name: Publish Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
run: |
release_id="${{ needs.prepare_release.outputs.release_id }}"
@@ -176,8 +181,6 @@ jobs:
release:
name: Release Build
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -197,8 +200,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -206,12 +207,15 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -223,23 +227,29 @@ jobs:
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
pnpm run prebuild ${{ matrix.target }}
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v1.0.0
env:
NODE_OPTIONS: '--max_old_space_size=4096'
@@ -254,14 +264,11 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ env.RELEASE_TAG }}
tagName: ${{ github.ref_name }}
releaseBody: 'Draft release, will be updated later.'
releaseDraft: true
prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }}
releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }}
tauriScript: pnpm
args: --target ${{ matrix.target }}
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
includeUpdaterJson: true
- name: Attest Windows bundles
if: matrix.os == 'windows-latest'
@@ -286,8 +293,6 @@ jobs:
release-for-linux-arm:
name: Release Build for Linux ARM
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -302,8 +307,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -311,28 +314,30 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
@@ -382,6 +387,12 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -409,7 +420,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "$RELEASE_TAG" \
gh release upload "${{ github.ref_name }}" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber
@@ -417,8 +428,6 @@ jobs:
release-for-fixed-webview2:
name: Release Build for Fixed WebView2
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -433,8 +442,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -442,28 +449,30 @@ jobs:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
@@ -476,6 +485,12 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
uses: tauri-apps/tauri-action@v1.0.0
env:
@@ -517,55 +532,22 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
gh release upload "$env:RELEASE_TAG" $files --clobber
gh release upload "${{ github.ref_name }}" $files --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
prepare-next-dev:
name: Prepare Next Dev Version
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
needs: [prepare_release, update_tag]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
with:
run_install: false
- name: Prepare version and changelog PR
run: |
pnpm install --frozen-lockfile --ignore-scripts
node scripts-workflow/release.mjs next-dev
release-update:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Release Update
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -581,19 +563,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-update-for-fixed-webview2:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -609,7 +589,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Submit to Winget
runs-on: ubuntu-latest
needs: [update_tag, release-update]
@@ -617,7 +597,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
fetch-depth: 0
- name: Get Version
run: |
@@ -634,7 +613,7 @@ jobs:
token: ${{ secrets.WINGET_TOKEN }}
notify-telegram:
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
if: ${{ !contains(github.ref_name, '-rc') }}
name: Notify Telegram
runs-on: ubuntu-latest
needs:
@@ -647,8 +626,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -661,7 +638,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+15 -6
View File
@@ -6,12 +6,6 @@ name: Check Formatting
on:
pull_request:
paths:
- "src-tauri/**"
- "**/*.rs"
- "rustfmt.toml"
- "rust-toolchain.toml"
- ".github/workflows/rustfmt.yml"
permissions:
contents: read
@@ -25,10 +19,25 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check Rust changes
id: check_rust
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- '**/*.rs'
- name: Skip if no Rust changes
if: steps.check_rust.outputs.rust != 'true'
run: echo "No Rust changes, skipping rustfmt."
- name: install Rust stable and rustfmt
if: steps.check_rust.outputs.rust == 'true'
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
node-version: '24.22.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 26.13.0
pnpm 12.16.1
nodejs 24.22.1
pnpm 12.15.0
rust 1.99.1
+6 -19
View File
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
### Prerequisites
1. **Install Rust and Node.js**
Follow the [Tauri prerequisites](https://tauri.app/start/prerequisites/) for your operating system. Use the Node.js version in [`.tool-versions`](.tool-versions); [`rust-toolchain.toml`](rust-toolchain.toml) selects the Rust version and required components when you run Cargo from this repository.
Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/).
### Windows Users
@@ -51,21 +51,18 @@ rustup set default-host x86_64-pc-windows-msvc
### Install Node.js Package Manager
[Corepack is no longer bundled with Node.js starting with Node.js 25](https://nodejs.org/api/corepack.html). Install it globally if it is not already available, then enable it:
Enable `corepack`:
```bash
npm install --global corepack
corepack enable
```
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
### Install Project Dependencies
Node.js dependencies:
```bash
pnpm install --frozen-lockfile
pnpm install
```
Ubuntu-only system packages:
@@ -81,24 +78,14 @@ pnpm run prebuild
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries
```
### Prepare the Development Service Source
`pnpm dev` builds the development service from a sibling checkout of [`clash-verge-service-ipc`](https://github.com/clash-verge-rev/clash-verge-service-ipc). The binaries downloaded by `pnpm prebuild` do not replace this source checkout.
Check the `clash_verge_service_ipc` dependency in [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml), then clone the service repository alongside `clash-verge-rev` and check out the matching tag. If the sibling directory already exists, reuse it and check its version rather than overwriting it. Without this checkout, `pnpm dev` fails when it tries to access `../clash-verge-service-ipc/Cargo.toml`.
### Run the Development Server
Run these commands from the root of `clash-verge-rev`:
```bash
pnpm dev # Build the development service and start the desktop app
pnpm dev:diff # Alias of pnpm dev
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature
pnpm dev # Standard
pnpm dev:diff # If an app instance already exists
pnpm dev:tauri # Run Tauri development mode
```
For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service.
### Build the Project
Standard build:
Generated
+82 -54
View File
@@ -33,7 +33,6 @@ dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.0",
"zeroize",
]
[[package]]
@@ -136,6 +135,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -1081,7 +1089,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1111,6 +1119,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
@@ -1130,7 +1139,6 @@ dependencies = [
"serde_json",
"serde_yaml_ng",
"sha2 0.11.1",
"smartstring",
"socket2",
"specta",
"specta-typescript",
@@ -1160,13 +1168,14 @@ dependencies = [
"tokio-stream",
"tokio-util",
"tracing",
"tristr",
"warp",
"webpki-roots",
"windows 0.62.2",
"windows-service",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip",
"zip 9.0.4",
]
[[package]]
@@ -1228,8 +1237,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.7.7"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.7#3ed29700b093190b5e7addcaa892e77f172af544"
version = "2.7.6"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6"
dependencies = [
"anyhow",
"kode-bridge",
@@ -1333,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1861,6 +1870,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -2014,7 +2034,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2273,7 +2293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2789,7 +2809,7 @@ dependencies = [
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.8",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3343,7 +3363,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -4031,9 +4051,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.23.0"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
dependencies = [
"sha2 0.11.1",
]
@@ -4214,7 +4234,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4381,7 +4401,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4858,7 +4878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5523,7 +5543,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6027,7 +6047,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6040,7 +6060,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6099,7 +6119,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6700,18 +6720,6 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"serde",
"static_assertions",
"version_check",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -6719,7 +6727,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7068,9 +7076,9 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
[[package]]
name = "tao"
version = "0.37.2"
version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [
"bitflags 2.13.2",
"block2",
@@ -7555,11 +7563,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.14.1"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"dirs 7.0.0",
"flate2",
"futures-util",
@@ -7584,7 +7592,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -7785,10 +7793,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8454,7 +8462,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.20",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8485,6 +8493,14 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "tristr"
version = "0.1.0"
dependencies = [
"serde",
"specta",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -8533,9 +8549,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.13.0"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
@@ -8563,7 +8579,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9105,7 +9121,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9126,7 +9142,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"windows-version",
]
@@ -9701,7 +9717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9742,11 +9758,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wry"
version = "0.57.1"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
@@ -9767,6 +9783,7 @@ dependencies = [
"objc2-foundation",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2 0.10.9",
@@ -10033,9 +10050,21 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zip"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"aes",
"bzip2",
@@ -10054,7 +10083,6 @@ dependencies = [
"sha1 0.11.0",
"time",
"typed-path",
"windows-sys 0.61.2",
"zeroize",
"zopfli",
"zstd",
+1 -2
View File
@@ -85,9 +85,8 @@ reqwest = { version = "0.13.5", features = [
] }
rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" }
tristr = { path = "../tristr", default-features = false, features = ["serde", "specta"] }
serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
+15 -23
View File
@@ -1,41 +1,33 @@
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land;
before publishing a release, distill them:
- user-visible changes only — no dev tooling, CI fixes, or refactors whose
effect is already covered by another entry
- describe outcomes, not internals: no internal module names, IPC or
protocol details, or implementation mechanics — write what the user
observes, not how the code achieves it
- one line per theme: merge entries that share a root cause
- each entry fits a single readable line (about 60 CJK characters); if it
overflows, it is packing multiple themes or detail that belongs in the
linked issue/PR — split it or drop the detail
- line grammar: open with a verb (修复/新增/优化), with the platform
first (Windows/macOS/Linux) when the change is platform-specific;
merge sub-symptoms as 主题:症状、症状; full-width punctuation,
「」 for UI labels, backticks for config keys and commands
- platform entries sit under their platform heading (bold, emoji
prefixed, ordered Windows/macOS/Linux, combined for pairs like
macOS/Linux) at the end of their section;
scripts-workflow/group_platforms.sh regroups, --check verifies
HTML comments are stripped by the update dialog's sanitizer and hidden on
GitHub, so they never reach users. -->
## v2.6.0
## v2.5.9
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复检测更新后又发布新版时,应用内更新失败
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
@@ -11,7 +11,6 @@ mod chatgpt;
mod claude;
mod disney_plus;
mod gemini;
mod muse;
mod netflix;
mod prime_video;
mod spotify;
@@ -31,7 +30,6 @@ enum UnlockCheck {
ChatGptWeb,
Claude,
Gemini,
MuseAi,
YouTubePremium,
BahamutAnime,
Netflix,
@@ -48,7 +46,6 @@ impl UnlockCheck {
Self::ChatGptWeb,
Self::Claude,
Self::Gemini,
Self::MuseAi,
Self::YouTubePremium,
Self::BahamutAnime,
Self::Netflix,
@@ -69,7 +66,6 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
Self::Claude => claude::CLAUDE_NAME,
Self::Gemini => gemini::GEMINI_NAME,
Self::MuseAi => muse::MUSE_AI_NAME,
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
Self::Netflix => netflix::NETFLIX_NAME,
@@ -87,7 +83,6 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
Self::Claude => claude::check_claude(client).await,
Self::Gemini => gemini::check_gemini(client).await,
Self::MuseAi => muse::check_muse_ai(client).await,
Self::YouTubePremium => youtube::check_youtube_premium(client).await,
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
Self::Netflix => netflix::check_netflix(client).await,
@@ -1,48 +0,0 @@
use reqwest::Client;
use crate::utils::{classify_restricted_status, get_trace_location};
use super::UnlockItem;
pub(crate) const MUSE_AI_NAME: &str = "Muse AI";
const MUSE_URL: &str = "https://muse.ai";
const CLOUDFLARE_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
// The aymh ("Are You a Human") gateway answers plain HTTP requests with a
// generic Meta error page that always contains the Facebook logo asset.
const AYMH_ERROR_MARKERS: &[&str] = &["Sorry, something went wrong", "static.facebook.com/images/logos"];
pub(super) async fn check_muse_ai(client: &Client) -> UnlockItem {
// muse.ai itself is served by Vercel, so resolve the exit region through
// the generic Cloudflare trace endpoint instead of a service-specific one.
let region = get_trace_location(client, CLOUDFLARE_TRACE_URL)
.await
.map(|code| code.trim().to_ascii_uppercase())
.map(|code| UnlockItem::region_label(&code));
let Ok(response) = client.get(MUSE_URL).send().await else {
return UnlockItem::checked(MUSE_AI_NAME, "Failed (Network Connection)", region);
};
let status = response.status();
let landed_on_aymh = response.url().host_str() == Some("auth.muse.ai");
let body = response.text().await.unwrap_or_default();
// Every region is redirected to the aymh anti-bot gateway before the app
// loads, and a non-browser HTTP client cannot finish the JavaScript
// challenge. It returns the Meta error page, so reaching this point only
// proves the exit can connect to Muse's auth infrastructure; availability
// cannot be verified over plain HTTP and is reported as `Risky`.
let behind_anti_bot = landed_on_aymh || AYMH_ERROR_MARKERS.iter().any(|marker| body.contains(marker));
let result = if behind_anti_bot {
"Risky"
} else if status.is_success() {
"Yes"
} else {
classify_restricted_status(status).unwrap_or("Failed")
};
UnlockItem::checked(MUSE_AI_NAME, result, region)
}
-77
View File
@@ -1,80 +1,3 @@
## v2.5.10
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复全局模式缺少 `GLOBAL` 策略组,无法单独选择全局出口
- 修复服务模式下,SSH 节点的 `private-key` 使用私钥文件时内核无法启动
- 修复服务模式下,内核更新的 GEO 数据库不会同步回应用目录,导致使用新分类的规则无法通过校验
- 修复订阅的 DNS 配置变化后,「DNS 覆写」实际已关闭但开关仍显示开启且没有提示
**🖥️ Windows**
- Windows:修复安装程序重新安装或覆盖时未提示退出运行中应用,导致卸载报错或覆盖失败
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
</details>
## v2.5.9
- **Mihomo(Meta) 内核升级至 v1.19.33**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复 重启应用后定时自动备份不执行的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复 WebDAV 设置清空后无法保存
**🍎 macOS**
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
## v2.5.8
<details>
-42
View File
@@ -129,7 +129,6 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'scripts-workflow/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
@@ -239,13 +238,6 @@ export default defineConfig([
},
},
},
{
files: ['scripts-workflow/*.mjs'],
languageOptions: {
globals: globals.node,
},
rules: { 'es-x/no-object-groupby': 'off' },
},
{
files: ['scripts/perf/*.{mjs,mts}'],
languageOptions: {
@@ -256,40 +248,6 @@ export default defineConfig([
},
},
},
{
files: ['scripts/react/*.{mjs,mts,ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.json',
},
},
settings: {
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: './scripts/react/tsconfig.json',
}),
],
},
},
{
files: ['scripts/react/*.{mjs,mts}'],
languageOptions: {
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.node.json',
},
},
},
{
files: ['scripts/react/{boundaries,material,scene}.tsx'],
// The isolated fixtures use full reloads and preserve aliased Hook names.
rules: {
'react-refresh/only-export-components': 'off',
'@eslint-react/no-unnecessary-use-prefix': 'off',
},
},
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
+1 -10
View File
@@ -1,15 +1,6 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/polyfills/*.js!",
"scripts/check-contract-changes.mjs!",
"scripts/react/scene.tsx!",
"scripts/react/profiler.ts!",
"scripts/react/boundaries.tsx!",
"scripts/react/material.tsx!",
"scripts/react/yaml.ts!",
"scripts/react/wdyr.ts!"
],
"entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
+12 -19
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.6.0",
"version": "2.5.9",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -14,7 +14,7 @@
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsrs -p . --noEmit && vite build",
"web:build": "tsc --noEmit && vite build",
"web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs",
@@ -32,7 +32,7 @@
"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": "tsrs -p . --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"knip:check": "knip",
@@ -40,11 +40,7 @@
"perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs",
"dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev",
"react:ab": "node scripts/react/ab.mjs",
"dev:wdyr": "cross-env CVR_REACT_DIAGNOSTICS=wdyr node scripts/dev.mjs dev",
"audit:react": "node scripts/react/doctor.mjs"
"perf:test": "node --test scripts/perf/report.test.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
@@ -64,7 +60,7 @@
"@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2",
"@tauri-apps/plugin-updater": "2.14.1",
"@tauri-apps/plugin-updater": "2.14.0",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
@@ -83,11 +79,12 @@
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.1.9",
"react-i18next": "17.1.8",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
@@ -100,7 +97,7 @@
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.0.0",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
@@ -121,27 +118,23 @@
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^14.0.0",
"glob": "^13.0.6",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.44.1",
"knip": "6.43.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"tsrs": "0.20.0-ts7.1.0-beta",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.1.0",
"ws": "8.22.1",
"playwright": "1.64.1",
"@welldone-software/why-did-you-render": "10.0.1",
"react-doctor": "0.9.21"
"ws": "8.22.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -153,5 +146,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.16.1"
"packageManager": "pnpm@12.15.0"
}
+317 -1172
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -10,11 +10,3 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- "@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta"
- "@tsrs/darwin-x64@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/win32-x64@0.20.0-ts7.1.0-beta"
- tsrs@0.20.0-ts7.1.0-beta
-7
View File
@@ -79,13 +79,6 @@
"matchPackageNames": ["typescript"],
"allowedVersions": "<7.0.0 || >=7.1.0"
},
{
"description": "Review tsrs bumps alone; the version encodes the pinned TypeScript release",
"matchPackageNames": ["tsrs", "/^@tsrs//"],
"groupName": "tsrs",
"rangeStrategy": "pin",
"automerge": false
},
{
"description": "Prevent project from supply chain attack",
"matchDatasources": ["npm"],
+1 -4
View File
@@ -41,10 +41,7 @@ fi
tmpfile=$(mktemp)
{
node -e '
const fs = require("node:fs");
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
' "$CHANGELOG"
cat "$CHANGELOG"
echo
echo ""
if [ -f "$HISTORY" ]; then
-803
View File
@@ -1,803 +0,0 @@
import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
const env = process.env
const repository = env.GITHUB_REPOSITORY
const refreshLabel = 'Refresh this PR from latest dev'
const publishLabel = 'Publish this approved release'
const minorLabel = 'Use the next minor version'
const checked = (body, label = refreshLabel) =>
(body || '')
.split('\n')
.some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase())
const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)'
const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`)
const stablePattern = new RegExp(`^v${versionPattern}$`)
const versionFiles = [
'package.json',
'src-tauri/tauri.conf.json',
'src-tauri/Cargo.toml',
'Cargo.lock',
]
const read = (path) => readFileSync(path, 'utf8')
const json = (path) => JSON.parse(read(path))
const run = (command, args, options = {}) =>
execFileSync(command, args, { encoding: 'utf8', ...options }).trim()
const git = (...args) => run('git', args)
const gitFile = (sha, path) =>
execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' })
const isRc = (tag) => tag.match(rcPattern)?.[0] === tag
const isRelease = (tag) => isRc(tag) || stablePattern.test(tag)
const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}`
const branchTag = (branch, prefix = 'release/') =>
branch.startsWith(prefix) && isRelease(branch.slice(prefix.length))
? branch.slice(prefix.length)
: undefined
const latest = (tags) =>
tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1)
const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`)
const endpoint = (path) =>
path === 'graphql' ? path : `repos/${repository}/${path}`
const list = (path) =>
JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat()
function api(path, body, method = 'POST') {
const response = run(
'gh',
['api', '--method', method, endpoint(path), '--input', '-'],
{
input: JSON.stringify(body),
},
)
const result = response ? JSON.parse(response) : null
if (result?.errors) throw new Error(JSON.stringify(result.errors))
return result
}
const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)]))
const canWrite = (login) =>
['write', 'maintain', 'admin'].includes(
get(`collaborators/${login}/permission`).permission,
)
function output(values) {
appendFileSync(
env.GITHUB_OUTPUT,
Object.entries(values)
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
)
}
function versionPatternFor(path) {
if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/
return path === 'Cargo.lock'
? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/
: /(^version\s*=\s*")[^"]+/m
}
function validateVersions(tag, sha) {
for (const path of versionFiles) {
const content = sha ? gitFile(sha, path) : read(path)
const version = path.endsWith('.json')
? JSON.parse(content).version
: content.match(versionPatternFor(path))?.[0].split('"').at(-1)
if (`v${version}` !== tag)
throw new Error(`${path} version does not match ${tag}.`)
}
}
function validateVersionCommit(tag, sha) {
validateVersions(tag, sha)
const commit = get(`commits/${sha}`)
if (!commit.commit.verification.verified || commit.parents.length !== 1)
throw new Error('The version commit must be signed and have one parent.')
const title = commit.commit.message.split('\n')[0]
if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag))
throw new Error('The final version commit has an unexpected title.')
const parent = commit.parents[0].sha
const changed = git('diff', '--name-only', parent, sha)
.split('\n')
.filter(Boolean)
if (
changed.some((path) => !versionFiles.includes(path)) ||
git('diff', '--summary', parent, sha)
)
throw new Error(
'The version commit changes files outside the four version files.',
)
for (const path of versionFiles) {
const before = gitFile(parent, path)
const after = gitFile(sha, path)
if (
before.replace(versionPatternFor(path), '$1VERSION') !==
after.replace(versionPatternFor(path), '$1VERSION')
)
throw new Error(`${path} contains changes other than its version.`)
}
}
function remoteRefs() {
return git('ls-remote', '--heads', '--tags', '--refs', 'origin')
.split('\n')
.filter(Boolean)
.map((line) => line.split('\t'))
}
function fetchBranches() {
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/dev:refs/remotes/origin/dev',
'+refs/heads/main:refs/remotes/origin/main',
)
}
function discardClosedCandidate(branch, base, prefix) {
const candidates = list(
`pulls?state=all&base=${base}&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
const latest = candidates[0]
if (!latest || candidates.some((pr) => pr.state === 'open'))
throw new Error(
`Branch ${branch} is reserved by an active or unknown candidate.`,
)
const pr = get(`pulls/${latest.number}`)
requireBotPr(pr, base, prefix)
if (pr.state !== 'closed' || pr.merged)
throw new Error(
`Close the unmerged candidate before retrying: ${pr.html_url}`,
)
updateRefs(pr.base.repo.node_id, [
{
name: `refs/heads/${branch}`,
beforeOid: pr.head.sha,
afterOid: '0'.repeat(40),
},
])
summary(`Released the unused branch from closed PR: ${pr.html_url}`)
}
function prepare() {
let refresh
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
if (checked(event.changes?.body?.from) || !checked(event.pull_request.body))
return
if (!canWrite(event.sender.login))
throw new Error('Refreshing a version PR requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open' || !checked(refresh.body)) return
requireBotPr(refresh, 'dev', 'release/')
}
fetchBranches()
git('checkout', '--detach', 'origin/dev')
const channel = refresh
? isRc(branchTag(refresh.head.ref))
? 'rc'
: 'stable'
: env.RELEASE_CHANNEL || 'rc'
if (!['rc', 'stable'].includes(channel))
throw new Error('Unknown release channel.')
const requested = refresh
? branchTag(refresh.head.ref)
: env.RELEASE_TAG || ''
if (
requested &&
!(channel === 'rc' ? isRc(requested) : stablePattern.test(requested))
)
throw new Error(
channel === 'rc'
? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.'
: 'Expected vX.Y.Z.',
)
const existing =
!refresh &&
list('pulls?state=open&base=dev&per_page=100').find(
(pr) =>
pr.head.repo?.full_name === repository &&
pr.user.login === 'github-actions[bot]' &&
branchTag(pr.head.ref) &&
isRc(branchTag(pr.head.ref)) === (channel === 'rc') &&
(!requested || branchTag(pr.head.ref) === requested),
)
if (existing) {
api(`issues/${existing.number}/labels`, { labels: ['release'] })
output({ existing_pr: existing.html_url })
summary(
`Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`,
)
return
}
const base = (requested || `v${json('package.json').version}`).split(
/[-+]/,
)[0]
if (!stablePattern.test(base))
throw new Error(`Invalid base version: ${base}`)
const refs = remoteRefs().map(([, ref]) => ref)
const tags = refs
.filter((ref) => ref.startsWith('refs/tags/'))
.map((ref) => ref.slice(10))
const reserved = refs
.filter((ref) => ref.startsWith('refs/heads/'))
.filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`)
.map(
(ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'),
)
.filter(Boolean)
const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`)
const occupied = [...tags, ...reserved]
const last = latest(occupied.filter(sameBase))
const tag =
requested ||
(channel === 'stable'
? base
: last
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
: `${base}-rc`)
if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`)
for (const [prefix, target] of [
['release/', 'dev'],
['publish/', 'main'],
]) {
const branch = `${prefix}${tag}`
if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref)
discardClosedCandidate(branch, target, prefix)
}
const sha = git('rev-parse', 'HEAD')
const body = releaseBody(tag, sha, tags, false)
summary(body)
if (
remoteRefs().some(
([, ref]) =>
ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`,
)
)
throw new Error(`Tag ${tag} already exists or is reserved for publication.`)
if (json('package.json').version !== tag.slice(1))
run('node', ['scripts/release-version.mjs', tag])
validateVersions(tag)
const title = titleFor(tag)
const branch = refresh
? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: `release/${tag}`
writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles })
}
function releaseBody(tag, sha, tags, publishing) {
const previous =
(isRc(tag) &&
latest(
tags.filter(
(value) =>
isRc(value) &&
value.startsWith(`${tag.split('-')[0]}-rc`) &&
value !== tag,
),
)) ||
latest(tags.filter((value) => stablePattern.test(value) && value !== tag))
const url = `${env.GITHUB_SERVER_URL}/${repository}`
let range = sha
let comparison = 'No previous release tag'
if (previous) {
git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`)
range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}`
comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})`
}
comparison += ` · **${git('rev-list', '--count', range)} commits**`
const commits = git('log', '--format=%h %s', range)
const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => {
if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps'
const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase()
return type === 'doc' ? 'docs' : type || 'other'
})
const labels = {
fix: 'Fixes',
feat: 'Features',
perf: 'Performance',
revert: 'Reverts',
refactor: 'Refactoring',
build: 'Build',
deps: 'Dependencies',
ci: 'CI',
docs: 'Documentation',
test: 'Tests',
chore: 'Maintenance',
style: 'Style',
other: 'Other',
}
const names = {
macos: 'macOS',
windows: 'Windows',
ui: 'UI',
ci: 'CI',
tun: 'TUN',
}
const order = Object.keys(labels)
const rank = (type) =>
order.includes(type) ? order.indexOf(type) : order.length
const scope =
Object.entries(groups)
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([type, lines]) => {
const entries = lines.map((line) => {
const hash = line.slice(0, line.indexOf(' '))
const title = line
.slice(hash.length + 1)
.replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '')
.replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '')
.replace(/\s{2,}/g, ' ')
.trim()
.replace(
/^\w+(?:\(([^)]*)\))?(!)?:\s*/,
(_, scope, breaking) =>
`${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`,
)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/[\\`*_[\]]/g, '\\$&')
return `- ${title} [#${hash}](${url}/commit/${hash})`
})
return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}`
})
.join('\n\n') || 'No new commits.'
return `## ${titleFor(tag)}
@clash-verge-rev/release
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
${
publishing
? `<!-- release-source:${sha} -->
After approval, select the checkbox below to publish.
Do not use the normal Squash or Rebase merge buttons.
- [ ] ${publishLabel}`
: `
- [ ] ${refreshLabel}
Refreshing requires a new approval.`
}
## Changes
${scope}
`
}
function writeVersionPr({ sha, body, refresh, title, branch, files }) {
// Create-only ref and expectedHeadOid prevent replacing another candidate.
api('git/refs', { ref: `refs/heads/${branch}`, sha })
let pr
try {
const commit = api('graphql', {
query: `mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`,
variables: {
input: {
branch: { repositoryNameWithOwner: repository, branchName: branch },
expectedHeadOid: sha,
message: { headline: title },
fileChanges: {
additions: files.map((path) => ({
path,
contents: readFileSync(path).toString('base64'),
})),
},
},
},
})
if (!commit.data?.createCommitOnBranch?.commit?.oid)
throw new Error('Signed version commit failed.')
if (refresh) {
const current = get(`pulls/${refresh.number}`)
if (
current.state !== 'open' ||
current.head.sha !== refresh.head.sha ||
current.base.ref !== refresh.base.ref ||
current.body !== refresh.body
)
throw new Error(
'The version pull request changed while preparing the update.',
)
const oid = commit.data.createCommitOnBranch.commit.oid
updateRefs(refresh.base.repo.node_id, [
{
name: `refs/heads/${refresh.head.ref}`,
beforeOid: refresh.head.sha,
afterOid: oid,
force: true,
},
{
name: `refs/heads/${branch}`,
beforeOid: oid,
afterOid: '0'.repeat(40),
},
])
for (const review of list(
`pulls/${refresh.number}/reviews?per_page=100`,
)) {
if (review.state === 'APPROVED')
api(
`pulls/${refresh.number}/reviews/${review.id}/dismissals`,
{
message:
'The version PR was refreshed from dev; please review again.',
},
'PUT',
)
}
pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH')
} else {
pr = api('pulls', {
head: branch,
base: 'dev',
title,
body,
})
}
} catch (error) {
if (refresh) {
try {
api(`git/refs/heads/${branch}`, {}, 'DELETE')
} catch {}
}
throw error
}
api(`issues/${pr.number}/labels`, { labels: ['release'] })
summary(`Version pull request: ${pr.html_url}`)
}
function updateRefs(repositoryId, refUpdates) {
api('graphql', {
query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`,
variables: { input: { repositoryId, refUpdates } },
})
}
function requireBotPr(pr, base, prefix) {
const tag = branchTag(pr.head.ref, prefix)
if (
!tag ||
pr.base.ref !== base ||
pr.base.repo.full_name !== repository ||
pr.head.repo?.full_name !== repository ||
pr.user.login !== 'github-actions[bot]'
)
throw new Error(
'Expected a release PR created by this repository workflow.',
)
return tag
}
function publishPr() {
const event = json(env.GITHUB_EVENT_PATH)
const pr = get(`pulls/${event.pull_request.number}`)
const tag = requireBotPr(pr, 'dev', 'release/')
if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha)
throw new Error('Expected the final merged version commit on dev.')
const sha = pr.merge_commit_sha
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const branch = `publish/${tag}`
const existing = list(
`pulls?state=open&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
if (existing.length) {
const publication = existing[0]
requireBotPr(publication, 'main', 'publish/')
if (publication.head.sha !== sha)
throw new Error(
'This tag is already reserved by a different publication snapshot.',
)
summary(`Existing publication PR: ${publication.html_url}`)
return
}
const refs = remoteRefs()
if (refs.some(([, ref]) => ref === `refs/tags/${tag}`))
throw new Error(`Tag ${tag} already exists.`)
git('merge-base', '--is-ancestor', 'origin/main', sha)
const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0]
if (head && head !== sha)
throw new Error('Publication branch already points to another commit.')
if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha })
const body = releaseBody(
tag,
sha,
refs
.filter(([, ref]) => ref.startsWith('refs/tags/'))
.map(([, ref]) => ref.slice(10)),
true,
)
const publication = api('pulls', {
head: branch,
base: 'main',
title: titleFor(tag),
body,
})
api(`issues/${publication.number}/labels`, { labels: ['release'] })
summary(`Publication PR: ${publication.html_url}\n\n${body}`)
}
function requireApproval(pr, sha) {
const reviews = new Map()
for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) {
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state))
reviews.set(review.user.login, review)
}
let approved = false
for (const review of reviews.values()) {
if (!canWrite(review.user.login)) continue
if (review.state === 'CHANGES_REQUESTED')
throw new Error('A reviewer with write access has requested changes.')
if (review.state === 'APPROVED' && review.commit_id === sha) approved = true
}
if (!approved)
throw new Error(
'The current PR head needs approval from a reviewer with write access.',
)
}
function releaseOutput(tag, sha) {
output({
release_tag: tag,
source_sha: sha,
channel: isRc(tag) ? 'rc' : 'stable',
})
}
function publish() {
const event = json(env.GITHUB_EVENT_PATH)
const tag = requireBotPr(event.pull_request, 'main', 'publish/')
if (
checked(event.changes?.body?.from, publishLabel) ||
!checked(event.pull_request.body, publishLabel)
)
throw new Error('Publishing requires a new checkbox selection.')
if (!canWrite(event.sender.login))
throw new Error('Publishing requires write access.')
const sha = event.pull_request.head.sha
const pr = get(`pulls/${event.pull_request.number}`)
requireBotPr(pr, 'main', 'publish/')
if (
pr.head.sha !== sha ||
!checked(pr.body, publishLabel) ||
!pr.body.includes(`<!-- release-source:${sha} -->`)
)
throw new Error(
'The publication snapshot changed after the checkbox selection.',
)
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}`
const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`)
if (existing) {
const ref = get(`git/ref/tags/${tag}`)
const object =
ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (
!object ||
object.object.type !== 'commit' ||
object.object.sha !== sha ||
object.message.trim() !== marker ||
Number(env.GITHUB_RUN_ATTEMPT) <= 1
)
throw new Error(
'An existing tag can only be recovered by its original release run.',
)
git('merge-base', '--is-ancestor', sha, 'origin/main')
summary(`Resuming ${tag} at ${sha} from this release run.`)
} else {
if (pr.state !== 'open' || pr.draft)
throw new Error('Expected an open, ready publication PR.')
requireApproval(pr, sha)
const main = git('rev-parse', 'origin/main')
git('merge-base', '--is-ancestor', main, sha)
git(
'-c',
'user.name=github-actions[bot]',
'-c',
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
'tag',
'--annotate',
'--no-sign',
'--message',
marker,
tag,
sha,
)
// GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity.
git(
'push',
'--atomic',
`--force-with-lease=refs/heads/main:${main}`,
`--force-with-lease=refs/heads/${pr.head.ref}:${sha}`,
`--force-with-lease=refs/tags/${tag}:`,
'origin',
`${sha}:refs/heads/main`,
`${sha}:refs/heads/${pr.head.ref}`,
`refs/tags/${tag}:refs/tags/${tag}`,
)
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
}
releaseOutput(tag, sha)
}
function validateTag() {
const tag = env.GITHUB_REF_NAME
if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.')
fetchBranches()
const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`)
const ref = get(`git/ref/tags/${tag}`)
const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (object && object.message.includes('Release workflow run:'))
throw new Error(
'Resume the original publication run for workflow-owned tags.',
)
git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`)
if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha)
throw new Error('The tag moved after this run started.')
for (const branch of ['main', 'dev'])
git('merge-base', '--is-ancestor', sha, `origin/${branch}`)
validateVersions(tag, sha)
releaseOutput(tag, sha)
}
function prepareDraft() {
const tag = env.RELEASE_TAG
verifyTarget()
const result = run('gh', [
'api',
'--paginate',
endpoint('releases?per_page=100'),
'--jq',
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`,
])
const release = result ? JSON.parse(result) : null
if (release && !release.draft)
throw new Error('This release is already public.')
const draft =
release ||
api('releases', {
tag_name: tag,
target_commitish: env.SOURCE_SHA,
name: `Clash Verge Rev ${tag}`,
body: 'Draft release, will be updated later.',
draft: true,
prerelease: isRc(tag),
})
output({ id: draft.id })
}
function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) {
const ref = get(`git/ref/tags/${tag}`)
const target =
ref.object.type === 'tag'
? get(`git/tags/${ref.object.sha}`).object
: ref.object
if (target.type !== 'commit' || target.sha !== sha)
throw new Error('The release tag no longer points to the validated source.')
}
function nextDev() {
let refresh
let tag = env.RELEASE_TAG
let source = env.SOURCE_SHA
let minor = false
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
const oldBody = event.changes?.body?.from
const body = event.pull_request.body
if (
checked(oldBody, minorLabel) === checked(body, minorLabel) &&
(checked(oldBody) || !checked(body))
)
return
if (!canWrite(event.sender.login))
throw new Error('Changing the next dev version requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open') return
tag = requireBotPr(refresh, 'dev', 'next-dev/')
if (
refresh.head.sha !== event.pull_request.head.sha ||
refresh.body !== body
)
throw new Error(
'The version update PR changed after the checkbox selection.',
)
minor = checked(body, minorLabel)
source = body.match(/<!-- next-dev-source:([a-f0-9]{40}) -->/)?.[1]
}
if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source))
throw new Error('The next dev version requires a stable release snapshot.')
verifyTarget(tag, source)
const release = get(`releases/tags/${tag}`)
if (release.draft || release.prerelease)
throw new Error('The stable release must be public before preparing dev.')
const branch = `next-dev/${tag}`
if (!refresh) {
const existing = list(
`pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)[0]
if (existing) {
requireBotPr(existing, 'dev', 'next-dev/')
summary(`Existing dev version PR: ${existing.html_url}`)
return
}
}
fetchBranches()
git('fetch', '--no-tags', 'origin', source)
git('merge-base', '--is-ancestor', source, 'origin/main')
git('merge-base', '--is-ancestor', source, 'origin/dev')
git('checkout', '--detach', 'origin/dev')
const sha = git('rev-parse', 'HEAD')
validateVersions(tag, source)
validateVersions(tag, sha)
if (read('Changelog.md') !== gitFile(source, 'Changelog.md'))
throw new Error(
'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.',
)
if (
!read('Changelog.md').split('\n').includes(`## ${tag}`) ||
read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`)
)
throw new Error('The released changelog is missing or already archived.')
const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt)
const patchTag = `v${major}.${minorVersion}.${patch + 1n}`
const minorTag = `v${major}.${minorVersion + 1n}.0`
const nextTag = minor ? minorTag : patchTag
run('pnpm', ['release-version', nextTag])
run('bash', ['scripts-workflow/bump_changelog.sh'])
const changelog = read('Changelog.md')
if (!changelog.includes('## v(Version Goes Here)'))
throw new Error('The changelog template has no version placeholder.')
writeFileSync(
'Changelog.md',
changelog.replace('## v(Version Goes Here)', `## ${nextTag}`),
)
validateVersions(nextTag)
const title = `Dev: ${nextTag}`
const body = `## ${title}
After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}).
<!-- next-dev-source:${source} -->
Patch: \`${patchTag}\` · Minor: \`${minorTag}\`
- [${minor ? 'x' : ' '}] ${minorLabel}
- [ ] ${refreshLabel}
Changing the version or refreshing requires a new approval.
`
writeVersionPr({
sha,
body,
refresh,
title,
branch: refresh
? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: branch,
files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'],
})
}
const commands = {
prepare,
'next-dev': nextDev,
'publish-pr': publishPr,
publish,
'validate-tag': validateTag,
'prepare-draft': prepareDraft,
'verify-target': verifyTarget,
}
const command = commands[process.argv[2]]
if (!command) throw new Error('Unknown release command.')
command()
+1 -1
View File
@@ -17,7 +17,7 @@
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/contract-parity.yml",
".github/workflows/frontend-check.yml",
".husky/pre-push"
]
}
+7 -6
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
}
if (!META_MAP[`${platform}-${arch}`]) {
throw new Error(`mihomo unsupported platform "${platform}-${arch}"`)
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
}
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
}
function mihomoAlpha() {
function clashMetaAlpha() {
const name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function mihomoAlpha() {
}
}
function mihomo() {
function clashMeta() {
const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -733,12 +733,13 @@ const tasks = [
{
name: 'verge-mihomo-alpha',
func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
retry: 5,
},
{
name: 'verge-mihomo',
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
func: () =>
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
retry: 5,
},
// After both sidecar tasks: it hashes what they downloaded.
-47
View File
@@ -1,47 +0,0 @@
# React diagnostics
`pnpm dev:profile` runs the usual development launcher with a root React
Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns
copies of the latest 1,000 standard Profiler callback records; `.clear()` drops
them. Records contain timing and commit metadata, never business props. Normal
development and production builds do not load the collector.
`pnpm dev:wdyr` instead initializes why-did-you-render before loading the app
and uses its development JSX runtime. It reports avoidable renders for
`ProxyItem`, `ProxyItemMini` and `ProxyTunCard` in the console. The modes are
mutually exclusive; `CVR_REACT_DIAGNOSTICS` accepts only `profiler` or `wdyr` on
the development server. Each mode uses its own Vite cache. Production builds
ignore this variable and include neither diagnostic entry nor runtime.
`pnpm audit:react` scans supported source files under `src`. Pass file paths to
limit the scan, for example `pnpm audit:react src/hooks/use-proxy-delay-state.ts`;
add `--json` for the original structured report. Diagnostics are advisory.
Execution errors and incomplete scans fail the command. Telemetry, remote scores
and supply-chain scanning are disabled; the command does not fix code, erase
historical diagnostics or run in CI.
Install the browser explicitly with `pnpm exec playwright install chromium`.
Then run `pnpm react:ab --base <ref> --head <ref>`. Both Git trees run against
one installed dependency set and the same fixed DNS, delay-hook and proxy-tab
fixtures. The command never installs a browser or changes either checkout.
It retains source hashes, dependency identity, browser/environment information,
behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`.
Each version and mode has separate Vite caches and output. Three pairs alternate
order in ordinary development, Strict Mode and production. A behavior failure
returns a nonzero exit status. DNS stale serialization and same-value renders
are observations so a known baseline can be compared with its fix. Append
`--assert dns` to require the head version to keep the latest serialized YAML in sync with
continuous field edits; this is the focused DNS regression assertion.
`--assert delay` requires same-value delay updates to retain their reference and memoized consumer,
while each changed field (including clearing elapsed time) propagates.
`--assert tabs` requires zero tab renders during unrelated parent updates. Use
`--assert dns --assert delay --assert tabs` for a cumulative candidate.
The fixtures execute the actual selected components and hook. IPC, data,
editor and UI rendering boundaries are replaced, and outgoing network requests
are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is
observed at the Material Paper boundary. These are component results, not native
Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains.
Production React does not emit Profiler callbacks. Timing is diagnostic and has
no hard acceptance threshold; measure native release CPU/RSS separately and
calibrate A/A before claiming a performance gain. Keep raw reports out of Git.
-596
View File
@@ -1,596 +0,0 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
symlink,
writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import react from '@vitejs/plugin-react'
import { chromium } from 'playwright'
import { build, createServer, preview } from 'vite'
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
assert: { type: 'string', multiple: true },
},
})
if (!values.base || !values.head)
throw new Error('Usage: pnpm react:ab --base <ref> --head <ref>')
const assertions = new Set(values.assert ?? [])
for (const name of assertions) {
if (!['dns', 'delay', 'tabs'].includes(name))
throw new Error(`Unknown regression assertion: ${name}`)
}
const harness = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(harness, '../..')
const output = await mkdtemp('/tmp/cvr-react-ab-')
const dependencies = path.join(root, 'node_modules')
const frozenHarness = path.join(output, 'harness')
await cp(harness, frozenHarness, {
recursive: true,
filter: (file) => !path.basename(file).startsWith('tsconfig'),
})
const files = [
'src/components/setting/mods/dns-viewer.tsx',
'src/hooks/use-proxy-delay-state.ts',
'src/components/home/proxy-tun-card.tsx',
]
const hash = (content) => createHash('sha256').update(content).digest('hex')
function git(...args) {
const result = spawnSync('git', args, {
cwd: root,
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(result.stderr.toString())
return result.stdout
}
const manifest = {
assertions: [...assertions],
environment: {
platform: process.platform,
architecture: process.arch,
node: process.version,
os: os.release(),
lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))),
},
harness: Object.fromEntries(
await Promise.all(
(await readdir(frozenHarness)).map(async (file) => [
file,
hash(await readFile(path.join(frozenHarness, file))),
]),
),
),
installedLock: hash(
await readFile(path.join(dependencies, '.pnpm/lock.yaml')),
),
sources: {},
runs: [],
}
let browser
try {
// Playwright intentionally fails here if its explicitly installed browser is missing.
browser = await chromium.launch({ headless: true })
manifest.environment.browser = browser.version()
for (const [variant, ref] of Object.entries({
base: values.base,
head: values.head,
})) {
const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim()
const source = path.join(output, variant)
await mkdir(source)
const archive = git('archive', tree, 'src')
const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive })
if (extracted.status !== 0) throw new Error(extracted.stderr.toString())
await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true })
await symlink(dependencies, path.join(source, 'node_modules'), 'dir')
manifest.sources[variant] = {
ref,
tree,
files: Object.fromEntries(
await Promise.all(
files.map(async (file) => [
file,
hash(await readFile(path.join(source, file))),
]),
),
),
}
}
for (const mode of ['development', 'strict', 'production']) {
const servers = {}
try {
for (const variant of ['base', 'head']) {
const source = path.join(output, variant)
const fixture = path.join(source, 'fixture')
const boundary = path.join(fixture, 'boundaries.tsx')
const aliases = [
'@/hooks/use-clash',
'@/hooks/use-verge',
'@/hooks/use-profiles',
'@/hooks/use-system-state',
'@/hooks/use-system-proxy-state',
'@/services/states',
'@/services/cmds',
'@/services/mutate',
'@/services/notice-service',
'@/components/base',
'@/components/shared/proxy-control-switches',
'react-i18next',
].map((find) => ({ find, replacement: boundary }))
const config = {
configFile: false,
root: fixture,
cacheDir: path.join(output, 'cache', variant, mode),
plugins: [react()],
resolve: {
alias: [
...aliases,
{
find: '@/services/delay',
replacement: path.join(fixture, 'delay.ts'),
},
{
find: /^@mui\/material$/,
replacement: path.join(fixture, 'material.tsx'),
},
{
find: 'fixture-material',
replacement: path.join(dependencies, '@mui/material/index.mjs'),
},
{ find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') },
{
find: 'fixture-yaml',
replacement: path.join(
dependencies,
'js-yaml/dist/js-yaml.mjs',
),
},
{ find: '@', replacement: path.join(source, 'src') },
{
find: /^react(?=\/|$)/,
replacement: path.join(dependencies, 'react'),
},
{
find: /^react-dom(?=\/|$)/,
replacement: path.join(dependencies, 'react-dom'),
},
],
dedupe: ['react', 'react-dom'],
},
define: { OS_PLATFORM: JSON.stringify(process.platform) },
server: {
host: '127.0.0.1',
port: 0,
hmr: false,
fs: {
allow: [
output,
await realpath(output),
await realpath(dependencies),
],
},
},
build: {
outDir: path.join(output, 'dist', variant, mode),
emptyOutDir: true,
},
preview: { host: '127.0.0.1', port: 0 },
}
if (mode === 'production') {
await build(config)
servers[variant] = await preview(config)
} else {
servers[variant] = await createServer(config)
await servers[variant].listen()
}
}
for (const [pair, order] of [
['base', 'head'],
['head', 'base'],
['base', 'head'],
].entries()) {
for (const variant of order) {
const label = `${mode}-${pair + 1}-${variant}`
const context = await browser.newContext()
const errors = []
const address = servers[variant].httpServer.address()
const origin = `http://127.0.0.1:${address.port}`
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin === origin)
return route.continue()
errors.push(`Unexpected network: ${route.request().url()}`)
return route.abort()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(String(error)))
const result = { label, variant, mode, pair: pair + 1, errors }
try {
await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`)
await page.waitForSelector('#delay', { timeout: 60000 })
await page.waitForFunction(
() => window.__REACT_AB__.read().listeners.length === 1,
)
result.tabs = await tabs(page)
if (variant === 'head' && assertions.has('tabs')) {
assert.equal(
result.tabs.unrelated,
0,
'Unrelated parent updates must not rerender the memoized tabs',
)
}
result.dns = await dns(page)
if (variant === 'head' && assertions.has('dns')) {
for (const edit of result.dns.edits) {
assert.equal(
edit.serialized.at(-1),
edit.value,
'Latest DNS serialization must match the edited field',
)
}
}
result.delay = await delay(page)
if (variant === 'head' && assertions.has('delay')) {
for (const [index, observation] of result.delay.entries()) {
assert.equal(
observation.sameReference,
index === 1,
'Preserve the reference only when all three delay fields match',
)
}
assert.equal(
result.delay[1].childRenders,
result.delay[0].childRenders,
'Equal delay state must not rerender the memoized consumer',
)
}
result.profile = await page.evaluate(() => {
const before = window.__CVR_REACT_PROFILER__.read()
window.__CVR_REACT_PROFILER__.clear()
return { before, after: window.__CVR_REACT_PROFILER__.read() }
})
assert.equal(result.profile.after.length, 0)
result.collector = await page.evaluate(() =>
window.__REACT_AB__.checkCollector(),
)
assert.deepEqual(result.collector, {
length: 1000,
first: '5',
last: '1004',
copied: true,
cleared: true,
})
if (mode !== 'production')
assert(
result.profile.before.length > 0 &&
result.profile.before.length <= 1000,
)
assert.deepEqual(errors, [])
assert.deepEqual(
await page.evaluate(() =>
window.__REACT_AB__.fixture.notices.filter(
(notice) => notice.error,
),
),
[],
)
result.ok = true
} catch (error) {
result.snapshot = await page
.evaluate(() => window.__REACT_AB__?.read())
.catch(() => null)
result.ok = false
result.error = String(error)
throw error
} finally {
manifest.runs.push(result)
await writeFile(
path.join(output, `${label}.json`),
JSON.stringify(result, null, 2),
)
await context.close()
console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`)
}
}
}
} finally {
for (const server of Object.values(servers)) {
if (server.close) await server.close()
else
await new Promise((resolve, reject) =>
server.httpServer.close((error) =>
error ? reject(error) : resolve(),
),
)
}
}
}
} finally {
await browser?.close()
await writeFile(
path.join(output, 'report.json'),
JSON.stringify(manifest, null, 2),
)
console.log(`Evidence: ${output}`)
}
async function settle(page) {
await page.waitForTimeout(100)
}
async function tabs(page) {
const buttons = page.locator('#tabs .MuiPaper-root')
const readTabs = () =>
page.evaluate(() =>
Object.fromEntries(
window.__REACT_AB__.fixture.tabRenders.map((tab) => [
tab.label.split('.').at(-1),
tab,
]),
),
)
assert.equal(await buttons.count(), 2)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
const before = await page.evaluate(
() => window.__REACT_AB__.fixture.tabRenders.length,
)
for (let index = 0; index < 5; index++) {
await page.evaluate(() => window.__REACT_AB__.update())
await settle(page)
}
const unrelated =
(await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) -
before
await buttons.nth(1).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, false)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'tun',
)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: true }))
await settle(page)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal((await readTabs()).systemProxy.active, false)
await buttons.nth(0).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'system',
)
await page.evaluate(() => {
window.__REACT_AB__.fixture.indicator = true
window.__REACT_AB__.fixture.verge.enable_tun_mode = true
window.__REACT_AB__.update()
})
await settle(page)
assert(Object.values(await readTabs()).every((tab) => tab.indicator))
await page.evaluate(() => {
window.__REACT_AB__.fixture.language = 'zh'
window.__REACT_AB__.update()
})
await settle(page)
const updated = Object.values(await readTabs())
assert(updated.every((tab) => tab.label.startsWith('zh:')))
assert(
(await page.locator('#tabs').textContent()).includes(
'zh:home.components.proxyTun.status.systemProxyEnabled',
),
)
const beforeColor = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
await page.evaluate(() => {
window.__REACT_AB__.fixture.theme = 'dark'
window.__REACT_AB__.update()
})
await settle(page)
const color = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
assert.notEqual(color, beforeColor)
return { unrelated, updated, beforeColor, color }
}
async function dns(page) {
await page.evaluate(() => window.__REACT_AB__.open())
const listen = page.locator('#dns input[placeholder=":53"]')
await page.waitForFunction(
() =>
document.querySelector('#dns input[placeholder=":53"]')?.value ===
':5353',
)
const edits = []
for (const value of [':54', ':55', ':56']) {
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(value)
await settle(page)
assert.equal(await listen.inputValue(), value)
edits.push({
value,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map(
(config) => config.dns.listen,
),
),
})
}
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(':')
await listen.pressSequentially('567', { delay: 0 })
await settle(page)
assert.equal(await listen.inputValue(), ':567')
edits.push({
value: ':567',
rapid: true,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen),
),
})
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 1,
)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen),
':567',
)
await page.evaluate(() => window.__REACT_AB__.open())
await page.waitForSelector('#dns')
await page
.getByRole('button', { name: /shared.actions.resetToDefault$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':53')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
const editor = page.locator('#fixture-monaco')
assert.match(await editor.inputValue(), /listen: ["']?:53/)
await editor.fill(
'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n',
)
await page
.getByRole('button', { name: /shared.editorModes.visualization$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':8053')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 2,
)
const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved)
assert.equal(saved[1].dns.listen, ':8053')
assert.equal(saved[1].hosts['example.test'], '127.0.0.1')
return { edits, saved }
}
async function delay(page) {
const updates = [
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 101 },
{ delay: 11, updatedAt: 101 },
{ delay: -1, updatedAt: 102 },
{ delay: 0, updatedAt: 103 },
{ delay: 1000000, elapsed: 20, updatedAt: 104 },
]
const observations = []
for (const update of updates) {
await page.evaluate((value) => {
window.__REACT_AB__.remember()
window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value)
}, update)
await settle(page)
const result = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(result.delay, update)
observations.push({
update,
sameReference: result.sameReference,
childRenders: result.childRenders,
})
}
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }),
)
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
0,
)
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({
delay: 1000000,
elapsed: 20,
updatedAt: 201,
}),
)
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().delay),
{ delay: 1000000, elapsed: 20, updatedAt: 201 },
)
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
let state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 1:Node 2'])
assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 })
await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' }))
await settle(page)
state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 2:Node 2'])
for (const name of ['DIRECT', 'Unknown']) {
await page.evaluate((name) => window.__REACT_AB__.update({ name }), name)
await settle(page)
const preset = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(preset.listeners, [])
assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 })
}
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
['Group 2:Node 2'],
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
[],
)
return observations
}
-68
View File
@@ -1,68 +0,0 @@
import { Switch as MuiSwitch } from '@mui/material'
import { fixture } from './state'
const translations = {
en: (key: string) => `en:${key}`,
zh: (key: string) => `zh:${key}`,
}
export const useTranslation = () => ({
t: translations[fixture.language as 'en' | 'zh'],
})
export const useVerge = () => ({ verge: fixture.verge })
export const useProfiles = () => ({ current: { uid: 'fixture' } })
export const useClash = () => ({
mutateClash: () => {
throw new Error('Unexpected mutateClash')
},
})
export const useSystemState = () => ({ isTunModeAvailable: fixture.available })
export const useSystemProxyState = () => ({ indicator: fixture.indicator })
export const useThemeMode = () => fixture.theme
export const getDnsConfigContent = async () => {
fixture.commands.push('getDnsConfigContent')
return fixture.initialDns
}
export const saveDnsConfig = async (config: any) => {
fixture.commands.push('saveDnsConfig')
fixture.saved.push(structuredClone(config))
}
export const validateDnsConfig = async () => {
fixture.commands.push('validateDnsConfig')
return { status: 'valid' }
}
export const applyDnsConfig = () => {
throw new Error('Unexpected applyDnsConfig')
}
export const mutate = (fn: () => unknown) => fn()
export const showNotice = {
error: (...args: any[]) => fixture.notices.push({ error: args }),
success: (...args: any[]) => fixture.notices.push({ success: args }),
}
export const Switch = MuiSwitch
export function BaseDialog({ open, title, children, onClose, onOk }: any) {
return open ? (
<section id="dns">
{title}
<button id="close-dns" onClick={onClose}>
close
</button>
<button id="save-dns" onClick={onOk}>
save
</button>
{children}
</section>
) : null
}
export function MonacoEditor({ value, onChange }: any) {
return (
<textarea
id="fixture-monaco"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export default function ProxyControlSwitches({ label }: any) {
return <output id="fixture-controls">{label}</output>
}
-36
View File
@@ -1,36 +0,0 @@
type Update = { delay: number; elapsed?: number; updatedAt: number }
const listeners = new Map<string, (update: Update) => void>()
const cache = new Map<string, Update>()
let finish: ((update: Update) => void) | undefined
const key = (name: string, group: string) => `${group}:${name}`
export const delayFixture = {
listeners,
emit(name: string, group: string, update: Update) {
cache.set(key(name, group), update)
listeners.get(key(name, group))?.(update)
},
finish(update: Update) {
if (!finish) throw new Error('No pending delay request')
finish(update)
finish = undefined
},
}
export default {
setListener(name: string, group: string, listener: (update: Update) => void) {
listeners.set(key(name, group), listener)
},
removeListener(name: string, group: string) {
listeners.delete(key(name, group))
},
getDelayUpdate(name: string, group: string) {
return cache.get(key(name, group))
},
getDelayFix() {
return -1
},
checkDelay() {
return new Promise<Update>((resolve) => {
finish = resolve
})
},
}
-77
View File
@@ -1,77 +0,0 @@
import { spawnSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import { globSync } from 'glob'
const { values, positionals } = parseArgs({
options: { json: { type: 'boolean' } },
allowPositionals: true,
})
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const files = positionals.length
? positionals
: globSync('src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,html}', {
cwd: root,
nodir: true,
}).sort()
if (files.length === 0) throw new Error('No source files found')
for (const file of files) {
if (
!/\.(?:[jt]sx?|[cm][jt]s|html)$/.test(file) ||
!statSync(path.resolve(root, file)).isFile()
)
throw new Error(`Expected a source file: ${file}`)
}
const executable = fileURLToPath(
new URL('../bin/react-doctor.js', import.meta.resolve('react-doctor')),
)
const result = spawnSync(
process.execPath,
[
executable,
...files,
'--json',
'--no-telemetry',
'--no-supply-chain',
'--blocking',
'none',
'--yes',
],
{
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
},
)
if (result.error) throw result.error
if (result.signal)
throw new Error(`React Doctor terminated by ${result.signal}`)
const report = JSON.parse(result.stdout)
if (values.json) {
process.stdout.write(result.stdout)
} else {
for (const diagnostic of report.diagnostics) {
console.log(
`${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.plugin}/${diagnostic.rule}\n ${diagnostic.message}`,
)
}
console.log(
`${report.summary.totalDiagnosticCount} advisory diagnostics across ${report.projects.reduce((count, project) => count + project.analyzedFileCount, 0)} analyzed files`,
)
}
// In 0.9.21, --blocking none also masks linter execution failures.
const incomplete = report.projects.filter((project) => !project.complete)
if (report.error) console.error(report.error.message)
for (const project of incomplete) {
console.error(
'Incomplete React Doctor scan:',
project.directory,
project.skippedCheckReasons,
)
}
process.exitCode =
result.status || (!report.ok || incomplete.length > 0 ? 1 : 0)
-5
View File
@@ -1,5 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>React component A/B</title></head>
<body><div id="root"></div><script type="module" src="./scene.tsx"></script></body>
</html>
-16
View File
@@ -1,16 +0,0 @@
import { Paper as MuiPaper } from 'fixture-material'
export * from 'fixture-material'
import { fixture } from './state'
export function Paper(props: any) {
const { onClick, elevation, children, sx } = props
if (onClick && sx?.maxWidth === 160) {
fixture.tabRenders.push({
active: elevation === 2,
indicator: Boolean(children[2]),
label: children[1].props.children,
})
}
return <MuiPaper {...props} />
}
-19
View File
@@ -1,19 +0,0 @@
import type { ProfilerOnRenderCallback } from 'react'
export type ProfileRecord = Parameters<ProfilerOnRenderCallback>
const records: ProfileRecord[] = []
export const recordRender: ProfilerOnRenderCallback = (...record) => {
if (records.length === 1000) records.shift()
records.push(record)
}
export const profiler = {
read: () => records.map((record) => [...record]),
clear: () => {
records.length = 0
},
}
Object.assign(window, { __CVR_REACT_PROFILER__: profiler })
-124
View File
@@ -1,124 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import {
Profiler,
StrictMode,
memo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot } from 'react-dom/client'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { DnsViewer } from '@/components/setting/mods/dns-viewer'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import { delayFixture } from './delay'
import { profiler, recordRender } from './profiler'
import { fixture } from './state'
const member = (name: string): any =>
name === 'Unknown'
? {
kind: 'unresolved',
ref: { kind: 'unresolved', name, reason: 'missing' },
}
: {
kind: 'node',
ref: { kind: 'node', recordId: name, name },
node: { name, history: [], source: { kind: 'core', proxyName: name } },
}
let update: (patch?: any) => void
let open: () => void
let delay: ReturnType<typeof useProxyDelayState>
let previous: unknown
let childRenders = 0
const DelayValue = memo(({ state }: any) => {
childRenders++
return <output id="delay">{JSON.stringify(state)}</output>
})
function DelayProbe({ name, group }: any) {
const resolved = useMemo(() => member(name), [name])
const current = useProxyDelayState(resolved, group)
useEffect(() => {
delay = current
})
return <DelayValue state={current.delayState} />
}
function Scene() {
const [state, setState] = useState({
revision: 0,
name: 'Node 1',
group: 'Group 1',
mounted: true,
})
const ref = useRef<any>(null)
useEffect(() => {
update = (patch = {}) =>
setState((old) => ({ ...old, ...patch, revision: old.revision + 1 }))
open = () => ref.current.open()
}, [])
const mode = fixture.theme
const theme = useMemo(() => createTheme({ palette: { mode } }), [mode])
return (
<ThemeProvider theme={theme}>
<main data-revision={state.revision}>
<section id="tabs" key={state.mounted ? 'mounted' : 'reset'}>
<ProxyTunCard />
</section>
<DnsViewer ref={ref} />
{state.mounted && <DelayProbe name={state.name} group={state.group} />}
</main>
</ThemeProvider>
)
}
const strict = new URLSearchParams(location.search).has('strict')
const app = (
<Profiler id="fixtures" onRender={recordRender}>
<Scene />
</Profiler>
)
createRoot(document.getElementById('root')!).render(
strict ? <StrictMode>{app}</StrictMode> : app,
)
Object.assign(window, {
__REACT_AB__: {
update: (patch?: any) => update(patch),
open: () => open(),
fixture,
delay: delayFixture,
request: () => {
void delay.onDelay()
},
remember: () => {
previous = delay.delayState
},
checkCollector: () => {
profiler.clear()
for (let index = 0; index < 1005; index++)
recordRender(String(index), 'update', 1, 2, 3, 4)
const records = profiler.read()
records[0][0] = 'changed copy'
const preserved = profiler.read()
const result = {
length: preserved.length,
first: preserved[0][0],
last: preserved.at(-1)![0],
copied: preserved[0][0] === '5',
cleared: false,
}
profiler.clear()
result.cleared = profiler.read().length === 0
return result
},
read: () => ({
fixture,
profile: profiler.read(),
childRenders,
sameReference: previous === delay.delayState,
delay: delay.delayState,
listeners: [...delayFixture.listeners.keys()],
}),
},
})
-13
View File
@@ -1,13 +0,0 @@
export const fixture = {
language: 'en',
theme: 'light' as 'light' | 'dark',
verge: { enable_tun_mode: false },
indicator: false,
available: true,
initialDns: 'dns:\n enable: true\n listen: ":5353"\n',
saved: [] as any[],
serialized: [] as any[],
tabRenders: [] as any[],
notices: [] as any[],
commands: [] as string[],
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"],
"@root/*": ["../../*"],
"fixture-material": ["../../node_modules/@mui/material"],
"fixture-yaml": ["../../node_modules/js-yaml"]
}
},
"include": ["./*.ts", "./*.tsx", "../../src"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"types": ["node"]
},
"include": ["./*.mjs", "./*.mts"]
}
-61
View File
@@ -1,61 +0,0 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
export function reactDiagnostics(mode: string | undefined): Plugin[] {
if (!mode) return []
return [
{
name: 'react-diagnostics',
enforce: 'pre',
apply: 'serve',
config() {
if (mode !== 'profiler' && mode !== 'wdyr')
throw new Error(`Unknown CVR_REACT_DIAGNOSTICS mode: ${mode}`)
return {
cacheDir: path.resolve('node_modules/.vite', mode),
...(mode === 'wdyr'
? {
oxc: {
jsx: {
importSource: '@welldone-software/why-did-you-render',
},
},
optimizeDeps: {
include: [
'@welldone-software/why-did-you-render',
'@welldone-software/why-did-you-render/jsx-dev-runtime',
],
},
}
: {}),
}
},
transformIndexHtml(html) {
if (mode !== 'wdyr') return html
const entry = 'src="./main.tsx"'
if (!html.includes(entry))
throw new Error('WDYR entry no longer matches index.html')
return html.replace(
entry,
`src="/@fs/${normalizePath(path.resolve('scripts/react/wdyr.ts'))}"`,
)
},
transform(code, id) {
if (mode !== 'profiler') return
if (id !== normalizePath(path.resolve('src/main.tsx'))) return
const open = '<React.StrictMode>'
const close = '</React.StrictMode>'
if (!code.includes(open) || !code.includes(close)) {
throw new Error('React profiling entry no longer matches main.tsx')
}
return {
code: `import { Profiler } from 'react'\nimport { recordRender } from '../scripts/react/profiler'\n${code}`
.replace(open, `<Profiler id="app" onRender={recordRender}>${open}`)
.replace(close, `${close}</Profiler>`),
map: null,
}
},
},
]
}
-10
View File
@@ -1,10 +0,0 @@
import whyDidYouRender from '@welldone-software/why-did-you-render'
import React from 'react'
whyDidYouRender(React, {
include: [/^(ProxyItem|ProxyItemMini|ProxyTunCard)$/],
trackAllPureComponents: false,
trackHooks: true,
})
await import('../../src/main')
-9
View File
@@ -1,9 +0,0 @@
import * as yaml from 'fixture-yaml'
export * from 'fixture-yaml'
import { fixture } from './state'
export function dump(value: any, options: any) {
fixture.serialized.push(structuredClone(value))
return yaml.dump(value, options)
}
+6 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.6.0"
version = "2.5.9"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -52,9 +52,9 @@ anyhow = { workspace = true }
tokio = { workspace = true }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tristr = { workspace = true }
serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
@@ -79,11 +79,12 @@ tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0"
zip = "9.1.3"
zip = "9.0.4"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11.1"
getrandom = "0.4.3"
futures = "0.3.34"
@@ -97,7 +98,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.2"
@@ -153,7 +154,7 @@ 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.1"
tauri-plugin-updater = "2.14.1"
tauri-plugin-updater = "2.14.0"
[dev-dependencies]
criterion = { workspace = true }
+3 -8
View File
@@ -21,7 +21,6 @@ ManifestDPIAwareness PerMonitorV2
!include "FileAssociation.nsh"
!include "Win\COM.nsh"
!include "Win\Propkey.nsh"
!include "Win\RestartManager.nsh"
!include "WinVer.nsh"
!include "LogicLib.nsh"
!include "StrFunc.nsh"
@@ -333,8 +332,6 @@ Function PageLeaveReinstall
${EndIf}
reinst_uninstall:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
HideWindow
ClearErrors
@@ -371,10 +368,7 @@ Function PageLeaveReinstall
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
Abort
${EndIf}
Return
reinst_done:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
FunctionEnd
; 5. Choose install directory page
@@ -907,7 +901,8 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
; Ensure startup folders exist
@@ -1106,7 +1101,7 @@ Section Uninstall
!insertmacro NSIS_HOOK_PREUNINSTALL
!endif
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
!insertmacro RemoveVergeService
+13 -18
View File
@@ -1,15 +1,12 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{
SilentUpdater,
updater::{DownloadEvent, InstallUpdateOutcome},
},
core::{SilentUpdater, updater::DownloadEvent},
feat,
utils::dirs,
};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel};
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
@@ -62,9 +59,10 @@ pub async fn restart_app() -> CmdResult<()> {
#[specta::specta]
pub async fn install_update(
app_handle: AppHandle,
version: std::string::String,
version: String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<InstallUpdateOutcome> {
) -> CmdResult<bool> {
let version: String = version;
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
@@ -81,25 +79,22 @@ pub fn cancel_update_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> {
let url: String = url;
let name: String = name;
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> {
let path: String = path;
feat::copy_icon_file(path, icon_info).await
}
+11 -11
View File
@@ -1,7 +1,7 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, feat};
use feat::LocalBackupFile;
use smartstring::alias::String;
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
@@ -17,15 +17,15 @@ pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
#[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<()> {
let filename: String = filename;
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<()> {
let filename: String = filename;
feat::restore_local_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
@@ -33,15 +33,15 @@ pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()
#[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> {
let source: String = source;
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<()> {
let filename: String = filename;
let destination: String = destination;
feat::export_local_backup(filename, destination).await.stringify_err()
}
+17 -16
View File
@@ -12,8 +12,8 @@ use crate::{
},
};
use clash_verge_logging::{Type, logging, logging_error};
use smartstring::alias::String;
use tokio::fs;
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
@@ -37,8 +37,8 @@ pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
#[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 {
let payload: String = payload;
feat::change_clash_mode(payload)
.await
.with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -47,14 +47,14 @@ 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>> {
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
Ok(Config::clash().await.data_arc().get_mode())
}
#[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>> {
let clash_core: String = clash_core;
logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await {
@@ -112,8 +112,8 @@ 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 url: String = url;
let result = match feat::test_delay(url).await {
Ok(delay) => delay,
Err(e) => {
@@ -148,12 +148,12 @@ pub fn take_dns_override_notice() -> bool {
#[tauri::command]
#[specta::specta]
pub async fn set_dns_override(
profile_uid: std::string::String,
profile_uid: String,
enabled: bool,
confirmation: Option<std::string::String>,
confirmation: Option<String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid.into();
let confirmation = confirmation.map(String::from);
let profile_uid: String = profile_uid;
let confirmation = confirmation;
feat::set_dns_override(profile_uid, enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
@@ -205,20 +205,21 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(info, Type::Config, "Config regenerated successfully");
}
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(())
}
#[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)),
}
@@ -243,7 +244,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
#[tauri::command]
#[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
pub async fn get_clash_logs() -> CmdResult<Vec<String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs)
Ok(logs.into_iter().collect())
}
@@ -2,6 +2,7 @@ pub use clash_verge_media_unlock::UnlockItem;
use reqwest::{Client, Proxy};
use std::time::Duration;
use tauri::{command, ipc::Channel};
use tristr::alias::String;
#[command]
#[specta::specta]
@@ -23,7 +24,7 @@ async fn create_client() -> Result<Client, String> {
.cookie_store(true)
.proxy(proxy)
.build()
.map_err(|error| format!("创建HTTP客户端失败: {error}"))
.map_err(|error| format!("创建HTTP客户端失败: {error}").into())
}
#[command]
@@ -40,5 +41,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
#[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
clash_verge_media_unlock::check_media_unlock_item(&client, &name)
.await
.map_err(Into::into)
}
+6 -16
View File
@@ -1,5 +1,5 @@
use anyhow::Result;
use smartstring::alias::String;
use tristr::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
@@ -8,9 +8,9 @@ pub type CmdResult<T = ()> = Result<T, CommandFailure>;
pub struct CommandFailure {
/// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<std::string::String>,
pub code: Option<String>,
/// Human-readable diagnostic detail.
pub detail: std::string::String,
pub detail: String,
/// Requested operation when known by the command.
#[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<crate::core::notification::FailedOperation>,
@@ -20,8 +20,8 @@ impl CommandFailure {
/// A failure with a stable code.
pub fn coded(code: &str, detail: impl std::fmt::Display) -> Self {
Self {
code: Some(code.to_owned()),
detail: detail.to_string(),
code: Some(code.into()),
detail: detail.to_string().into(),
operation: None,
}
}
@@ -30,7 +30,7 @@ impl CommandFailure {
pub fn plain(detail: impl std::fmt::Display) -> Self {
Self {
code: None,
detail: detail.to_string(),
detail: detail.to_string().into(),
operation: None,
}
}
@@ -50,16 +50,6 @@ impl From<&str> for CommandFailure {
}
}
impl From<std::string::String> for CommandFailure {
fn from(detail: std::string::String) -> Self {
Self {
code: None,
detail,
operation: None,
}
}
}
impl From<String> for CommandFailure {
fn from(detail: String) -> Self {
Self::plain(detail)
+13 -6
View File
@@ -5,6 +5,7 @@ use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
@@ -24,8 +25,8 @@ pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
Ok(SystemProxy {
enable,
server: format!("{host}:{port}"),
bypass,
server: format!("{host}:{port}").into(),
bypass: bypass.into(),
})
}
@@ -39,7 +40,10 @@ pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
};
let Autoproxy { enable, url } = auto_proxy;
Ok(AutoProxy { enable, url })
Ok(AutoProxy {
enable,
url: url.into(),
})
}
#[tauri::command]
@@ -52,10 +56,10 @@ pub fn get_embedded_server_port() -> CmdResult<u16> {
#[specta::specta]
pub fn get_system_hostname() -> String {
match gethostname().into_string() {
Ok(name) => name,
Ok(name) => name.into(),
Err(os_string) => {
let fallback = format!("{os_string:?}");
fallback.trim_matches('"').to_string()
fallback.trim_matches('"').into()
}
}
}
@@ -64,6 +68,9 @@ pub fn get_system_hostname() -> String {
#[specta::specta]
pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
.into_iter()
.map(Into::into)
.collect()
}
#[tauri::command]
@@ -77,7 +84,7 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfa
let mut result = Vec::new();
for interface in interfaces {
if names.contains(&interface.name) {
if names.iter().any(|name| name == &interface.name) {
result.push(interface.into());
}
}
+26 -31
View File
@@ -19,17 +19,17 @@ use crate::{
use clash_verge_draft::{Draft, SharedDraft};
use clash_verge_logging::{Type, logging, logging_error};
use scopeguard::defer;
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use tristr::alias::String;
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
fn profile_import_error(err: &anyhow::Error) -> std::string::String {
fn profile_import_error(err: &anyhow::Error) -> String {
if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) {
return cause.to_string();
return cause.to_string().into();
}
format!("导入订阅失败: {err:#}")
format!("导入订阅失败: {err:#}").into()
}
#[tauri::command]
@@ -68,7 +68,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it,
Err(e) => {
@@ -98,9 +98,9 @@ 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 {
let active_id: String = active_id;
let over_id: String = over_id;
match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -121,9 +121,9 @@ 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 {
pub async fn create_profile(item: ProfileCreate, file_data: Option<String>) -> CmdResult {
let item: PrfItem = item.into();
let file_data = file_data.map(String::from);
let file_data = file_data;
match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => {
profiles_save_file_safe()
@@ -138,8 +138,8 @@ 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 {
let index: String = index;
match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()),
Err(e) => {
@@ -151,8 +151,8 @@ 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 index: String = index;
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await;
@@ -191,6 +191,10 @@ pub async fn delete_profile(index: std::string::String) -> CmdResult {
drop(config_update_guard);
drop(profile_write_guard);
if should_update {
logging_error!(Type::Config, Config::sync_dns_override().await);
}
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
}
@@ -260,6 +264,7 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationO
handle::Handle::notify_profile_changed(current);
}
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(ValidationOutcome::Valid)
}
@@ -320,7 +325,6 @@ 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>,
}
@@ -328,13 +332,9 @@ pub struct ProfileSelection {
#[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>,
}
@@ -356,15 +356,10 @@ impl From<ProfileCreate> for PrfItem {
#[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>>,
pub extra: Option<crate::config::PrfExtra>,
@@ -431,9 +426,9 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm
#[tauri::command]
#[specta::specta]
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
pub async fn patch_profile(index: String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into();
let index: String = index.into();
let index: String = index;
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()
@@ -474,7 +469,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
@@ -499,7 +494,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();
@@ -519,7 +514,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",
@@ -530,12 +525,12 @@ 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)
+6 -5
View File
@@ -15,6 +15,7 @@ use std::{
collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
use tristr::alias::String;
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
@@ -48,7 +49,7 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.filter_map(|name| name.as_str())
.filter(|name| !name.is_empty() && *name != "GLOBAL")
.filter(|name| seen.insert(*name))
.map(str::to_owned)
.map(String::from)
.collect()
}
@@ -63,7 +64,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.into_iter()
.flat_map(|providers| providers.keys())
.filter_map(|name| name.as_str())
.map(str::to_owned)
.map(String::from)
.collect::<Vec<_>>();
all_providers.sort();
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
@@ -75,7 +76,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.flatten()
.filter_map(|group| group.as_mapping())
.filter_map(|group| {
let name = group.get("name")?.as_str()?.to_owned();
let name: String = group.get("name")?.as_str()?.into();
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
all_providers.clone()
} else {
@@ -85,14 +86,14 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.into_iter()
.flatten()
.filter_map(|provider| provider.as_str())
.map(str::to_owned)
.map(String::from)
.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())
.map(|types| types.split('|').map(String::from).collect())
.unwrap_or_default();
Some((
name,
+6 -11
View File
@@ -1,8 +1,8 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
use anyhow::{Context as _, anyhow};
use smartstring::alias::String;
use std::collections::HashMap;
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
@@ -17,7 +17,7 @@ pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>>
#[tauri::command]
#[specta::specta]
pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
pub async fn get_runtime_yaml() -> CmdResult<String> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
@@ -36,22 +36,17 @@ pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
#[derive(serde::Serialize, specta::Type)]
#[serde(transparent)]
pub struct RuntimeLogs(
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
HashMap<String, Vec<(String, String)>>,
);
pub struct RuntimeLogs(HashMap<String, Vec<(String, String)>>);
#[tauri::command]
#[specta::specta]
pub fn take_discarded_keys_notice() -> Option<std::string::String> {
crate::enhance::take_discarded_keys_notice().map(Into::into)
pub fn take_discarded_keys_notice() -> Option<String> {
crate::enhance::take_discarded_keys_notice()
}
#[tauri::command]
#[specta::specta]
pub async fn get_runtime_proxy_chain_config(
proxy_chain_exit_node: std::string::String,
) -> CmdResult<std::string::String> {
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
+9 -11
View File
@@ -1,4 +1,4 @@
use super::CmdResult;
use super::{CmdResult, CommandFailure};
use crate::{
cmd::StringifyErr as _,
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
@@ -10,19 +10,16 @@ use crate::{
},
utils::dirs,
};
use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use clash_verge_logging::{Type, logging, logging_error};
use tokio::fs;
use tristr::alias::String;
/// 保存profiles的配置
#[tauri::command]
#[specta::specta]
pub async fn save_profile_file(
index: std::string::String,
file_data: Option<std::string::String>,
) -> CmdResult<ValidationOutcome> {
let index: String = index.into();
let file_data = file_data.map(String::from);
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> {
let index: String = index;
let file_data = file_data;
let file_data = match file_data {
Some(d) => d,
None => return Ok(ValidationOutcome::Valid),
@@ -153,7 +150,7 @@ async fn handle_saved_profile_file(
Err(e) => {
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {e:#}");
restore_original(file_path, original_content, original_existed).await?;
return Err(e.to_string().into());
return Err(CommandFailure::plain(e));
}
}
@@ -168,6 +165,7 @@ async fn handle_saved_profile_file(
);
match CoreManager::global().update_config_forced().await {
Ok(outcome) if outcome.is_valid() => {
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(ValidationOutcome::Valid)
}
@@ -180,7 +178,7 @@ async fn handle_saved_profile_file(
Err(err) => {
logging!(error, Type::Config, "[cmd配置save] 运行时配置应用错误: {err:#}");
restore_original(file_path, original_content, original_existed).await?;
Err(err.to_string().into())
Err(CommandFailure::plain(err))
}
}
}
+4 -3
View File
@@ -7,6 +7,7 @@ use crate::{
service::{SERVICE_MANAGER, ServiceStatus, request_runtime_provider_sync},
},
};
use tristr::alias::String;
#[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")]
@@ -74,7 +75,7 @@ where
if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code));
}
let reason = reasons.join("\n");
let reason: String = reasons.join("\n").into();
fallback(reason.clone())
.await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
@@ -179,7 +180,7 @@ mod tests {
let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus {
service_sha256: String::new(),
service_sha256: std::string::String::new(),
protocol: ProtocolInfo::current(),
cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(),
@@ -203,7 +204,7 @@ mod tests {
assert_eq!(
result,
Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}")
reason: format!("verge-mihomo.exe: {reason}").into()
})
);
} else {
+1
View File
@@ -2,6 +2,7 @@ use crate::core::{
notification::{self, PendingFailure},
runstate::{RUN_STATE, RunStateView},
};
use tristr::alias::String;
/// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command]
+9 -13
View File
@@ -4,18 +4,14 @@ use crate::{
config::{Config, IVerge},
core, feat,
};
use smartstring::alias::String;
use tristr::alias::String;
#[tauri::command]
#[specta::specta]
pub async fn save_webdav_config(
url: std::string::String,
username: std::string::String,
password: std::string::String,
) -> CmdResult<()> {
let url: String = url.into();
let username: String = username.into();
let password: String = password.into();
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
let url: String = url;
let username: String = username;
let password: String = password;
let patch = IVerge {
webdav_url: Some(url),
webdav_username: Some(username),
@@ -48,15 +44,15 @@ pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
#[tauri::command]
#[specta::specta]
pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
let filename: String = filename;
feat::delete_webdav_backup(filename).await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
let filename: String = filename;
feat::restore_webdav_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
+74 -24
View File
@@ -10,6 +10,7 @@ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
str::FromStr as _,
};
use tristr::alias::String;
#[derive(Default, Debug, Clone)]
pub struct IClashTemp(pub Mapping);
@@ -84,12 +85,12 @@ impl IClashTemp {
#[cfg(unix)]
map.insert(
"external-controller-unix".into(),
Self::guard_external_controller_ipc().into(),
Self::guard_external_controller_ipc().as_str().into(),
);
#[cfg(windows)]
map.insert(
"external-controller-pipe".into(),
Self::guard_external_controller_ipc().into(),
Self::guard_external_controller_ipc().as_str().into(),
);
map.insert("tun".into(), tun_config.into());
cors_map.insert("allow-private-network".into(), true.into());
@@ -134,12 +135,18 @@ impl IClashTemp {
config.insert("mixed-port".into(), mixed_port.into());
config.insert("socks-port".into(), socks_port.into());
config.insert("port".into(), port.into());
config.insert("external-controller".into(), ctrl.into());
config.insert("external-controller".into(), ctrl.as_str().into());
#[cfg(unix)]
config.insert("external-controller-unix".into(), external_controller_unix.into());
config.insert(
"external-controller-unix".into(),
external_controller_unix.as_str().into(),
);
#[cfg(windows)]
config.insert("external-controller-pipe".into(), external_controller_pipe.into());
config.insert(
"external-controller-pipe".into(),
external_controller_pipe.as_str().into(),
);
config
}
@@ -166,9 +173,9 @@ impl IClashTemp {
port: Self::guard_port(config),
server: Self::guard_client_ctrl(config),
secret: config.get("secret").and_then(|value| match value {
Value::String(val_str) => Some(val_str.clone()),
Value::Bool(val_bool) => Some(val_bool.to_string()),
Value::Number(val_num) => Some(val_num.to_string()),
Value::String(val_str) => Some(val_str.clone().into()),
Value::Bool(val_bool) => Some(val_bool.to_string().into()),
Value::Number(val_num) => Some(val_num.to_string().into()),
_ => None,
}),
}
@@ -180,45 +187,88 @@ impl IClashTemp {
/// 的严格 `BaseConfig` 反序列化,因此即使核心返回的字段与插件结构体不匹配也能取到。
pub fn get_mode(&self) -> Option<String> {
self.0.get("mode").and_then(|value| match value {
Value::String(val_str) => Some(val_str.clone()),
Value::String(val_str) => Some(val_str.clone().into()),
_ => None,
})
}
fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 {
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
let mut port = config
.get(key)
.get("redir-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(default);
.unwrap_or(7895);
if port == 0 {
port = default;
port = 7895;
}
port
}
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR)
}
#[cfg(target_os = "linux")]
fn guard_tproxy_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY)
let mut port = config
.get("tproxy-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_TPROXY);
if port == 0 {
port = network::ports::DEFAULT_TPROXY;
}
port
}
fn guard_mixed_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED)
let raw_value = config.get("mixed-port");
let mut port = raw_value
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_MIXED);
if port == 0 {
port = network::ports::DEFAULT_MIXED;
}
port
}
fn guard_socks_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS)
let mut port = config
.get("socks-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7898);
if port == 0 {
port = 7898;
}
port
}
fn guard_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP)
let mut port = config
.get("port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7899);
if port == 0 {
port = 7899;
}
port
}
pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
@@ -233,7 +283,7 @@ impl IClashTemp {
false => Cow::Borrowed(val_str),
};
SocketAddr::from_str(&val).ok().map(|s| s.to_string())
SocketAddr::from_str(&val).ok().map(|s| s.to_string().into())
}
None => None,
})
@@ -247,7 +297,7 @@ impl IClashTemp {
if socket.ip().is_unspecified() {
socket.set_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
}
socket.to_string()
socket.to_string().into()
}
Err(_) => "127.0.0.1:9097".into(),
}
+3 -3
View File
@@ -20,10 +20,10 @@ use backon::{ExponentialBuilder, Retryable as _};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf};
use tokio::sync::{Mutex, MutexGuard, OnceCell};
use tokio::time::sleep;
use tristr::alias::String;
pub(crate) struct Config {
clash_config: Draft<IClashTemp>,
@@ -205,7 +205,7 @@ impl Config {
Self::write_runtime_file(&yaml).await
}
pub(crate) async fn runtime_config_yaml() -> Result<std::string::String> {
pub(crate) async fn runtime_config_yaml() -> Result<String> {
let runtime = Self::runtime().await;
AsyncHandler::spawn_blocking(move || {
let runtime_lastest = runtime.latest_arc();
@@ -216,7 +216,7 @@ impl Config {
.or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
let yaml_str = serde_yaml_ng::to_string(config)?;
Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str))
Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str).into())
})
.await
.map_err(|join| anyhow!("runtime serialization task failed: {join}"))?
+1 -2
View File
@@ -4,8 +4,8 @@ use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use tristr::alias::String;
use super::{Config, IVerge};
use crate::core::handle::Handle;
@@ -110,7 +110,6 @@ impl DnsOverrideState {
pub struct ProfileDnsSettings {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub confirmation: Option<String>,
}
+6 -2
View File
@@ -7,6 +7,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cell::Cell;
use std::future::Future;
use tristr::alias::String;
const NONCE_LENGTH: usize = 12;
@@ -28,7 +29,7 @@ pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut combined = nonce.to_vec();
combined.extend(ciphertext);
Ok(STANDARD.encode(combined))
Ok(STANDARD.encode(combined).into())
}
pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> {
@@ -46,7 +47,10 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
.decrypt(nonce.into(), ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?;
String::from_utf8(plaintext).map_err(|e| e.into())
// from_utf8 exists only on the std String type; the tristr alias shadows the bare name.
std::string::String::from_utf8(plaintext)
.map_err(|e| e.into())
.map(Into::into)
}
pub fn serialize_encrypted<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
-9
View File
@@ -14,15 +14,6 @@ mod verge;
pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
macro_rules! patch_opt_field {
($lv:expr, $rv:expr, $key:tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.clone();
}
};
}
pub(crate) use patch_opt_field;
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
}
+3 -2
View File
@@ -18,6 +18,7 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde_yaml_ng::Value;
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
use tristr::alias::String;
#[derive(Clone, Copy)]
struct MixedPortFallback {
@@ -141,8 +142,8 @@ impl Config {
}
pub(crate) fn block_startup_core(error: &anyhow::Error) {
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string());
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string().into());
report_fallback_error(error.to_string().into());
}
pub(crate) fn startup_core_block_reason() -> Option<String> {
+114 -97
View File
@@ -1,5 +1,3 @@
use super::patch_opt_field;
use crate::{
config::profiles,
utils::{
@@ -11,9 +9,9 @@ use crate::{
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::time::Duration;
use tokio::fs;
use tristr::alias::String;
// TODO, use other re-export
use reqwest_dav::re_exports::url::form_urlencoded;
use tauri::Url;
@@ -32,24 +30,18 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
#[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)]
#[serde(default)]
pub struct PrfItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>,
/// enum value: remote | local | script | merge
#[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 file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -64,20 +56,16 @@ pub struct PrfItem {
pub option: Option<PrfOption>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub home: Option<String>,
#[serde(skip)]
#[specta(type = Option<std::string::String>)]
pub file_data: Option<String>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)]
pub struct PrfSelected {
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub now: Option<String>,
}
@@ -93,7 +81,6 @@ pub struct PrfExtra {
#[serde(default)]
pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -116,15 +103,10 @@ pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_auto_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub merge: Option<String>,
#[specta(type = Option<std::string::String>)]
pub script: Option<String>,
#[specta(type = Option<std::string::String>)]
pub rules: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxies: Option<String>,
#[specta(type = Option<std::string::String>)]
pub groups: Option<String>,
}
@@ -133,18 +115,19 @@ impl PrfOption {
match (one, other) {
(Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone();
patch_opt_field!(result, b_ref, user_agent);
patch_opt_field!(result, b_ref, with_proxy);
patch_opt_field!(result, b_ref, self_proxy);
patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
patch_opt_field!(result, b_ref, allow_auto_update);
patch_opt_field!(result, b_ref, update_interval);
patch_opt_field!(result, b_ref, merge);
patch_opt_field!(result, b_ref, script);
patch_opt_field!(result, b_ref, rules);
patch_opt_field!(result, b_ref, proxies);
patch_opt_field!(result, b_ref, groups);
patch_opt_field!(result, b_ref, timeout_seconds);
result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
result.danger_accept_invalid_certs =
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
result.update_interval = b_ref.update_interval.or(result.update_interval);
result.merge = b_ref.merge.clone().or(result.merge);
result.script = b_ref.script.clone().or(result.script);
result.rules = b_ref.rules.clone().or(result.rules);
result.proxies = b_ref.proxies.clone().or(result.proxies);
result.groups = b_ref.groups.clone().or(result.groups);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
Some(result)
}
(Some(a_ref), None) => Some(a_ref.clone()),
@@ -182,45 +165,13 @@ impl PrfItem {
}
}
async fn append_companion_item(item: &mut Self) -> Result<String> {
profiles::profiles_append_item_safe(item).await?;
item.uid
.clone()
.ok_or_else(|| anyhow::anyhow!("appended companion item has no uid"))
}
async fn ensure_companion_items(
merge: &mut Option<String>,
script: &mut Option<String>,
rules: &mut Option<String>,
proxies: &mut Option<String>,
groups: &mut Option<String>,
) -> Result<()> {
if merge.is_none() {
*merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?);
}
if script.is_none() {
*script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?);
}
if rules.is_none() {
*rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?);
}
if proxies.is_none() {
*proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?);
}
if groups.is_none() {
*groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?);
}
Ok(())
}
async fn from_local(
name: String,
desc: String,
file_data: Option<String>,
option: Option<&PrfOption>,
) -> Result<Self> {
let uid = help::get_uid("L").into();
let uid = help::get_uid("L");
let file = format!("{uid}.yaml").into();
let opt_ref = option.as_ref();
let update_interval = opt_ref.and_then(|o| o.update_interval);
@@ -230,7 +181,31 @@ impl PrfItem {
let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.clone());
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self {
uid: Some(uid),
itype: Some("local".into()),
@@ -369,11 +344,11 @@ impl PrfItem {
.and_then(|value| value.to_str().ok())
.and_then(normalize_profile_home_url);
let uid = help::get_uid("R").into();
let uid = help::get_uid("R");
let file = format!("{uid}.yaml").into();
let name = name
.map(|s| s.to_owned())
.unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into()));
.unwrap_or_else(|| filename.unwrap_or_else(|| "Remote File".into()));
let data = resp.text();
let data = data.trim_start_matches('\u{feff}');
@@ -384,7 +359,31 @@ impl PrfItem {
bail!("profile does not contain `proxies` or `proxy-providers`");
}
Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
if merge.is_none() {
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self {
uid: Some(uid),
@@ -415,7 +414,7 @@ impl PrfItem {
let (id, template) = if let Some(uid) = uid {
(uid, tmpl::ITEM_MERGE.into())
} else {
(help::get_uid("m").into(), tmpl::ITEM_MERGE_EMPTY.into())
(help::get_uid("m"), tmpl::ITEM_MERGE_EMPTY.into())
};
let file = format!("{id}.yaml").into();
@@ -430,11 +429,7 @@ impl PrfItem {
}
pub(super) fn from_script(uid: Option<String>) -> Self {
let id = if let Some(uid) = uid {
uid
} else {
help::get_uid("s").into()
};
let id = if let Some(uid) = uid { uid } else { help::get_uid("s") };
let file = format!("{id}.js").into(); // js ext
Self {
uid: Some(id),
@@ -446,30 +441,46 @@ impl PrfItem {
}
}
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
let uid = help::get_uid(prefix).into();
fn from_rules() -> Self {
let uid = help::get_uid("r");
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some(itype.into()),
itype: Some("rules".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(template.into()),
file_data: Some(tmpl::ITEM_RULES.into()),
..Default::default()
}
}
fn from_rules() -> Self {
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
}
fn from_proxies() -> Self {
Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES)
let uid = help::get_uid("p");
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("proxies".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_PROXIES.into()),
..Default::default()
}
}
fn from_groups() -> Self {
Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS)
let uid = help::get_uid("g");
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("groups".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_GROUPS.into()),
..Default::default()
}
}
pub(crate) async fn read_file(&self) -> Result<String> {
@@ -485,20 +496,26 @@ impl PrfItem {
}
}
macro_rules! current_field {
($name:ident, $field:ident) => {
pub(crate) fn $name(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.$field.as_ref())
}
};
}
impl PrfItem {
current_field!(current_merge, merge);
current_field!(current_script, script);
current_field!(current_rules, rules);
current_field!(current_proxies, proxies);
current_field!(current_groups, groups);
pub(crate) fn current_merge(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.merge.as_ref())
}
pub(crate) fn current_script(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.script.as_ref())
}
pub(crate) fn current_rules(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.rules.as_ref())
}
pub(crate) fn current_proxies(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.proxies.as_ref())
}
pub(crate) fn current_groups(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.groups.as_ref())
}
}
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
+64 -43
View File
@@ -1,5 +1,5 @@
use super::{
PrfOption, patch_opt_field,
PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::core::notify::{Refresh, announce};
@@ -17,7 +17,6 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::num::NonZeroUsize;
use std::{
collections::{HashMap, HashSet},
@@ -30,6 +29,7 @@ use std::{
};
use tauri_plugin_mihomo::models::{Proxies, ProxyType};
use tokio::{fs, task::JoinHandle};
use tristr::alias::String;
pub(crate) static PROFILE_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -51,7 +51,6 @@ const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IProfiles {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>,
pub items: Option<Vec<PrfItem>>,
@@ -86,6 +85,14 @@ impl ProfileDeletePlan {
}
}
macro_rules! patch {
($lv: expr, $rv: expr, $key: tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.to_owned();
}
};
}
impl IProfiles {
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
@@ -107,7 +114,7 @@ impl IProfiles {
let mut home_changed = false;
for item in items.iter_mut() {
if item.uid.is_none() {
item.uid = Some(help::get_uid("d").into());
item.uid = Some(help::get_uid("d"));
}
if item.itype.as_deref() == Some("remote") {
item.option
@@ -236,15 +243,15 @@ impl IProfiles {
for each in items.iter_mut() {
if each.uid.as_deref() == Some(uid) {
patch_opt_field!(each, item, itype);
patch_opt_field!(each, item, name);
patch_opt_field!(each, item, desc);
patch_opt_field!(each, item, file);
patch_opt_field!(each, item, url);
patch_opt_field!(each, item, selected);
patch_opt_field!(each, item, extra);
patch_opt_field!(each, item, updated);
patch_opt_field!(each, item, option);
patch!(each, item, itype);
patch!(each, item, name);
patch!(each, item, desc);
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
self.items = Some(items);
return self.save_file().await;
@@ -412,21 +419,6 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
profiles_append_item_safe(item).await
}
/// Applies `edit` to the committed profiles; commits only when the edit succeeds.
async fn with_profiles_edit<F, Fut>(edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<IProfiles>> + Send,
{
Config::profiles()
.await
.with_data_modify(|profiles| async move {
let profiles = edit(profiles).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await
@@ -442,19 +434,43 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, it
}
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.patch_item(index, item).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.reorder(active_id, over_id).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_save_file_safe() -> Result<()> {
with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|profiles| async move {
profiles.save_file().await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await
Config::profiles()
.await
.with_data_modify(|mut profiles| async move {
profiles.update_item(index, item).await?;
Ok((profiles, ()))
})
.await
}
#[derive(Debug, PartialEq, Eq)]
@@ -696,21 +712,19 @@ where
Ok(())
}
fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> {
let current = profiles.current.clone()?;
profiles
.items
.as_mut()?
.iter_mut()
.find(|item| item.uid.as_ref() == Some(&current))
}
/// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move {
let Some(item) = current_item_mut(&mut profiles) else {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
@@ -745,7 +759,14 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move {
let Some(item) = current_item_mut(&mut profiles) else {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use tristr::alias::String;
use crate::enhance::field::use_keys;
+85 -77
View File
@@ -1,6 +1,6 @@
use crate::config::Config;
use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
constants::network,
utils::{dirs, help},
};
@@ -8,7 +8,7 @@ use anyhow::Result;
use clash_verge_logging::{Type, logging};
use log::LevelFilter;
use serde::{Deserialize, Serialize};
use smartstring::alias::String;
use tristr::alias::String;
/// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
@@ -39,7 +39,7 @@ pub struct IVerge {
pub traffic_graph: Option<bool>,
/// show memory info (only for Mihomo)
/// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -432,90 +432,98 @@ impl IVerge {
#[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) {
patch_opt_field!(self, patch, app_log_level);
patch_opt_field!(self, patch, app_log_max_size);
patch_opt_field!(self, patch, app_log_max_count);
macro_rules! patch {
($key: tt) => {
if patch.$key.is_some() {
self.$key = patch.$key.clone();
}
};
}
patch_opt_field!(self, patch, language);
patch_opt_field!(self, patch, theme_mode);
patch_opt_field!(self, patch, tray_event);
patch_opt_field!(self, patch, env_type);
patch_opt_field!(self, patch, start_page);
patch_opt_field!(self, patch, startup_script);
patch_opt_field!(self, patch, traffic_graph);
patch_opt_field!(self, patch, enable_memory_usage);
patch_opt_field!(self, patch, enable_group_icon);
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
patch!(app_log_level);
patch!(app_log_max_size);
patch!(app_log_max_count);
patch!(language);
patch!(theme_mode);
patch!(tray_event);
patch!(env_type);
patch!(start_page);
patch!(startup_script);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(enable_group_icon);
patch!(pause_render_traffic_stats_on_blur);
#[cfg(target_os = "macos")]
patch_opt_field!(self, patch, tray_icon);
patch_opt_field!(self, patch, menu_icon);
patch_opt_field!(self, patch, menu_order);
patch_opt_field!(self, patch, proxy_group_tools_position);
patch_opt_field!(self, patch, notice_position);
patch_opt_field!(self, patch, collapse_navbar);
patch_opt_field!(self, patch, common_tray_icon);
patch_opt_field!(self, patch, sysproxy_tray_icon);
patch_opt_field!(self, patch, tun_tray_icon);
patch!(tray_icon);
patch!(menu_icon);
patch!(menu_order);
patch!(proxy_group_tools_position);
patch!(notice_position);
patch!(collapse_navbar);
patch!(common_tray_icon);
patch!(sysproxy_tray_icon);
patch!(tun_tray_icon);
patch_opt_field!(self, patch, enable_tun_mode);
patch_opt_field!(self, patch, enable_auto_launch);
patch_opt_field!(self, patch, enable_silent_start);
patch_opt_field!(self, patch, enable_hover_jump_navigator);
patch_opt_field!(self, patch, hover_jump_navigator_delay);
patch!(enable_tun_mode);
patch!(enable_auto_launch);
patch!(enable_silent_start);
patch!(enable_hover_jump_navigator);
patch!(hover_jump_navigator_delay);
#[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_port);
patch!(verge_redir_port);
#[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_enabled);
patch!(verge_redir_enabled);
#[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_port);
patch!(verge_tproxy_port);
#[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_enabled);
patch_opt_field!(self, patch, verge_mixed_port);
patch_opt_field!(self, patch, verge_socks_port);
patch_opt_field!(self, patch, verge_socks_enabled);
patch_opt_field!(self, patch, verge_port);
patch_opt_field!(self, patch, verge_http_enabled);
patch_opt_field!(self, patch, enable_system_proxy);
patch_opt_field!(self, patch, enable_proxy_guard);
patch_opt_field!(self, patch, enable_bypass_check);
patch_opt_field!(self, patch, use_default_bypass);
patch_opt_field!(self, patch, system_proxy_bypass);
patch_opt_field!(self, patch, proxy_guard_duration);
patch_opt_field!(self, patch, proxy_auto_config);
patch_opt_field!(self, patch, pac_file_content);
patch_opt_field!(self, patch, proxy_host);
patch_opt_field!(self, patch, theme_setting);
patch_opt_field!(self, patch, web_ui_list);
patch_opt_field!(self, patch, clash_core);
patch_opt_field!(self, patch, hotkeys);
patch_opt_field!(self, patch, enable_global_hotkey);
patch!(verge_tproxy_enabled);
patch!(verge_mixed_port);
patch!(verge_socks_port);
patch!(verge_socks_enabled);
patch!(verge_port);
patch!(verge_http_enabled);
patch!(enable_system_proxy);
patch!(enable_proxy_guard);
patch!(enable_bypass_check);
patch!(use_default_bypass);
patch!(system_proxy_bypass);
patch!(proxy_guard_duration);
patch!(proxy_auto_config);
patch!(pac_file_content);
patch!(proxy_host);
patch!(theme_setting);
patch!(web_ui_list);
patch!(clash_core);
patch!(hotkeys);
patch!(enable_global_hotkey);
patch_opt_field!(self, patch, auto_close_connection);
patch_opt_field!(self, patch, auto_check_update);
patch_opt_field!(self, patch, default_latency_test);
patch_opt_field!(self, patch, default_latency_timeout);
patch_opt_field!(self, patch, enable_auto_delay_detection);
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
patch_opt_field!(self, patch, enable_builtin_enhanced);
patch_opt_field!(self, patch, proxy_layout_column);
patch_opt_field!(self, patch, test_list);
patch_opt_field!(self, patch, auto_log_clean);
patch_opt_field!(self, patch, enable_auto_backup_schedule);
patch_opt_field!(self, patch, auto_backup_interval_hours);
patch_opt_field!(self, patch, auto_backup_on_change);
patch!(auto_close_connection);
patch!(auto_check_update);
patch!(default_latency_test);
patch!(default_latency_timeout);
patch!(enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced);
patch!(proxy_layout_column);
patch!(test_list);
patch!(auto_log_clean);
patch!(enable_auto_backup_schedule);
patch!(auto_backup_interval_hours);
patch!(auto_backup_on_change);
patch_opt_field!(self, patch, webdav_url);
patch_opt_field!(self, patch, webdav_username);
patch_opt_field!(self, patch, webdav_password);
patch!(webdav_url);
patch!(webdav_username);
patch!(webdav_password);
#[cfg(target_os = "macos")]
patch_opt_field!(self, patch, enable_tray_speed);
patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
patch_opt_field!(self, patch, tray_inline_outbound_modes);
patch_opt_field!(self, patch, enable_auto_light_weight_mode);
patch_opt_field!(self, patch, auto_light_weight_minutes);
patch_opt_field!(self, patch, enable_dns_settings);
patch_opt_field!(self, patch, home_cards);
patch_opt_field!(self, patch, enable_external_controller);
patch!(enable_tray_speed);
patch!(tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes);
patch!(enable_dns_settings);
patch!(home_cards);
patch!(enable_external_controller);
}
pub fn get_log_level(&self) -> LevelFilter {
-7
View File
@@ -14,13 +14,6 @@ pub mod network {
}
}
pub mod sysproxy {
/// Single source of the macOS default bypass list; both proxy backends compose from it.
#[cfg(target_os = "macos")]
pub const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
}
pub mod timing {
use super::Duration;
+19 -12
View File
@@ -7,10 +7,11 @@ use crate::{
use anyhow::Result;
use chrono::Local;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use std::{
path::PathBuf,
sync::{
OnceLock,
Arc, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering},
},
time::{Duration, UNIX_EPOCH},
@@ -19,6 +20,7 @@ use tokio::{
fs,
sync::{Mutex, watch},
};
use tristr::alias::String;
const DEFAULT_INTERVAL_HOURS: u64 = 24;
const MIN_INTERVAL_HOURS: u64 = 1;
@@ -81,9 +83,8 @@ impl Default for AutoBackupSettings {
}
pub struct AutoBackupManager {
settings: Arc<RwLock<AutoBackupSettings>>,
settings_tx: watch::Sender<AutoBackupSettings>,
// The watch channel is the single settings store; no duplicate copies.
settings_rx: watch::Receiver<AutoBackupSettings>,
runner_started: AtomicBool,
exec_lock: Mutex<()>,
last_backup: AtomicI64,
@@ -93,10 +94,10 @@ impl AutoBackupManager {
pub fn global() -> &'static Self {
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| {
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
Self {
settings_tx,
settings_rx,
settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
settings_tx: tx,
runner_started: AtomicBool::new(false),
exec_lock: Mutex::new(()),
last_backup: AtomicI64::new(0),
@@ -106,14 +107,20 @@ impl AutoBackupManager {
pub async fn init(&self) -> Result<()> {
let settings = Self::load_settings().await;
let _ = self.settings_tx.send_replace(settings);
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
}
pub async fn refresh_settings(&self) -> Result<()> {
let settings = Self::load_settings().await;
let _ = self.settings_tx.send_replace(settings);
{
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings);
Ok(())
}
@@ -187,7 +194,7 @@ impl AutoBackupManager {
}
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
let snapshot = *self.settings_rx.borrow();
let snapshot = *self.settings.read();
if trigger.is_schedule() && !snapshot.schedule_enabled {
return Ok(());
@@ -205,7 +212,7 @@ impl AutoBackupManager {
return Ok(());
}
let file_name = create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into()).await?;
let file_name = create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug())).await?;
self.last_backup.store(Local::now().timestamp(), Ordering::Release);
if let Err(err) = cleanup_auto_backups().await {
@@ -233,8 +240,8 @@ impl AutoBackupManager {
fn append_auto_suffix(file_name: &str, slug: &str) -> String {
match file_name.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"),
None => format!("{file_name}{AUTO_MARKER}{slug}"),
Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}").into(),
None => format!("{file_name}{AUTO_MARKER}{slug}").into(),
}
}
+1 -1
View File
@@ -5,7 +5,6 @@ use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging};
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
use smartstring::alias::String;
use std::{
collections::HashMap,
env::{consts::OS, temp_dir},
@@ -15,6 +14,7 @@ use std::{
time::Duration,
};
use tokio::{fs, time::timeout};
use tristr::alias::String;
use zip::write::SimpleFileOptions;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
+10 -1
View File
@@ -7,7 +7,6 @@ use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_m
use anyhow::{Result, bail};
use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use std::{
collections::HashMap,
fmt,
@@ -18,6 +17,7 @@ use std::{
},
};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt as _, ShortcutState};
use tristr::alias::String;
/// Enum representing all available hotkey functions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -466,3 +466,12 @@ impl Hotkey {
(del_list, add_list)
}
}
impl Drop for Hotkey {
fn drop(&mut self) {
let app_handle = handle::Handle::app_handle();
if let Err(e) = app_handle.global_shortcut().unregister_all() {
logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e);
}
}
}
+42 -53
View File
@@ -40,47 +40,8 @@ impl LightweightState {
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
struct WindowListener {
handler_id: AtomicU32,
event: &'static str,
label: &'static str,
}
impl WindowListener {
fn setup(&self, on_event: impl Fn() + Send + 'static) {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label);
}
let handler_id = window.listen(self.event, move |_event| on_event());
self.handler_id.store(handler_id, Ordering::Release);
}
}
fn cancel(&self) {
let id = self.handler_id.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了{}监听", self.label);
}
}
}
static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://close-requested",
label: "窗口关闭",
};
static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://focus",
label: "窗口焦点",
};
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
@@ -193,27 +154,55 @@ pub async fn add_light_weight_timer() {
}
fn setup_window_close_listener() {
WINDOW_CLOSE_LISTENER.setup(|| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听");
}
let handler_id = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release);
}
}
fn cancel_window_close_listener() {
WINDOW_CLOSE_LISTENER.cancel();
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口关闭监听");
}
}
fn setup_webview_focus_listener() {
WEBVIEW_FOCUS_LISTENER.setup(|| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听");
}
let handler_id = window.listen("tauri://focus", move |_event| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release);
}
}
fn cancel_webview_focus_listener() {
WEBVIEW_FOCUS_LISTENER.cancel();
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口焦点监听");
}
}
async fn setup_light_weight_timer() {
+13 -10
View File
@@ -10,6 +10,7 @@ use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr as _,
};
use tristr::alias::String;
/// The config key of the Mixed Port, named once so callers stop spelling it out.
pub(crate) const MIXED_PORT_KEY: &str = "mixed-port";
@@ -163,7 +164,7 @@ pub(crate) fn probe_listener(request: &ListenerProbe) -> ListenerProbeOutcome {
Ok(address) => address,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: error.to_string(),
message: error.to_string().into(),
};
}
};
@@ -201,7 +202,7 @@ pub(crate) fn probe_proxy_port_change(
Ok(claims) => claims,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: format!("current proxy listener configuration is invalid: {error:#}"),
message: format!("current proxy listener configuration is invalid: {error:#}").into(),
};
}
}
@@ -212,7 +213,7 @@ pub(crate) fn probe_proxy_port_change(
Ok(claims) => claims,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: format!("candidate proxy listener configuration is invalid: {error:#}"),
message: format!("candidate proxy listener configuration is invalid: {error:#}").into(),
};
}
};
@@ -401,7 +402,8 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
claim.socket_addr(),
transport_name(claim.transport),
error
),
)
.into(),
};
}
Err(error) => {
@@ -412,7 +414,8 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
claim.socket_addr(),
transport_name(claim.transport),
error
),
)
.into(),
};
}
}
@@ -739,7 +742,7 @@ mod tests {
let port = listener.local_addr()?.port();
assert_eq!(
probe_listener(&ListenerProbe {
address: format!("0.0.0.0:{port}"),
address: format!("0.0.0.0:{port}").into(),
transports: vec![ListenerTransport::Tcp],
}),
ListenerProbeOutcome::Conflict {
@@ -755,7 +758,7 @@ mod tests {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
address: format!("127.0.0.1:{port}").into(),
transports: vec![ListenerTransport::Tcp],
});
assert_eq!(
@@ -773,7 +776,7 @@ mod tests {
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let port = listener.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
address: format!("127.0.0.1:{port}").into(),
transports: vec![ListenerTransport::Tcp],
});
assert_eq!(
@@ -809,7 +812,7 @@ mod tests {
assert_eq!(
probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
address: format!("127.0.0.1:{port}").into(),
transports: vec![ListenerTransport::Tcp],
}),
ListenerProbeOutcome::Available
@@ -822,7 +825,7 @@ mod tests {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = socket.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
address: format!("127.0.0.1:{port}").into(),
transports: vec![ListenerTransport::Udp],
});
assert_eq!(
+2 -2
View File
@@ -15,7 +15,6 @@ use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::StageRuntimeOutcome;
use scopeguard::defer;
use smartstring::alias::String;
use std::{
collections::HashSet,
path::PathBuf,
@@ -23,6 +22,7 @@ use std::{
time::{Duration, Instant},
};
use tauri_plugin_mihomo::Error as MihomoError;
use tristr::alias::String;
/// A staging result separated from the policy that acts on it.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -92,7 +92,7 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
match crate::core::service::stage_runtime_by_service(path).await {
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
Ok(StageRequest::Refused { code, message }) => {
let message = message.into();
let message = message;
if StageRequest::is_about_the_bundle(code) {
StageAttempt::RefusedTheBundle(message)
} else {
+7 -12
View File
@@ -7,10 +7,10 @@ use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use scopeguard::defer;
use smartstring::alias::String;
use std::path::Path;
#[cfg(target_os = "windows")]
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tristr::alias::String;
#[cfg(any(target_os = "windows", test))]
const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admin: bool) -> bool {
@@ -194,7 +194,7 @@ async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, Chec
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
record_unavailable: impl FnOnce(std::string::String),
record_unavailable: impl FnOnce(String),
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
@@ -217,7 +217,7 @@ where
// A lost start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}"));
record_unavailable(format!("{error:#}").into());
return Err(error);
}
logging!(
@@ -435,10 +435,6 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready");
}
// A Sidecar that was already running never loaded this generation.
if matches!(*mode, RunningMode::NotRunning) {
Config::runtime().await.apply();
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
@@ -496,7 +492,6 @@ impl CoreManager {
{
anyhow::bail!("Sidecar did not become ready after service uninstall");
}
Config::runtime().await.apply();
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
},
@@ -655,7 +650,7 @@ impl CoreManager {
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason.into()));
},
)
.await
@@ -693,7 +688,7 @@ impl CoreManager {
}
let reason = match state.health {
ServiceHealth::Unavailable(reason) => reason,
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".into(),
_ => return Ok(()),
};
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
@@ -717,7 +712,7 @@ impl CoreManager {
if Handle::global().is_exiting() {
return Err(error.context("application exited before Sidecar fallback"));
}
let reason = format!("{error:#}");
let reason: String = format!("{error:#}").into();
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
let result = self.start_core_by_sidecar().await;
@@ -1595,7 +1590,7 @@ mod tests {
started.store(true, Ordering::SeqCst);
Ok(())
},
|reason| store.observe(ServiceHealth::Unavailable(reason)),
|reason| store.observe(ServiceHealth::Unavailable(reason.into())),
)
.await;
+1 -5
View File
@@ -5,7 +5,7 @@ mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging, logging_error};
use clash_verge_logging::{LogRing, Type, logging};
use std::{
fmt,
sync::{
@@ -274,10 +274,6 @@ impl CoreManager {
pub(crate) fn finish_config_update(&self) {
self.config_update_in_progress.store(false, Ordering::Release);
// Spawned because the caller may still hold the config write lock this waits for.
crate::process::AsyncHandler::spawn(|| async {
logging_error!(Type::Config, crate::config::Config::sync_dns_override().await);
});
}
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
+11 -8
View File
@@ -16,6 +16,7 @@ use std::num::NonZeroUsize;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
use tristr::alias::String;
pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
let detail = error.to_string();
@@ -27,7 +28,7 @@ pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
}
#[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
fn sidecar_config_without_tun(yaml: &str) -> Result<String> {
use serde_yaml_ng::{Mapping, Value};
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
@@ -40,7 +41,7 @@ fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
tun.as_mapping_mut()
.context("invalid Sidecar TUN configuration")?
.insert(Value::from("enable"), Value::Bool(false));
Ok(serde_yaml_ng::to_string(&config)?)
Ok(serde_yaml_ng::to_string(&config)?.into())
}
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
@@ -153,7 +154,7 @@ impl CoreManager {
pub async fn get_clash_logs(&self) -> Result<Vec<String>> {
match *self.get_running_mode() {
RunningMode::Service => service::get_clash_logs_by_service().await,
RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs()),
RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs().into_iter().map(Into::into).collect()),
RunningMode::NotRunning => Ok(Vec::new()),
}
}
@@ -257,7 +258,9 @@ impl CoreManager {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy_owned(line);
// from_utf8_lossy_owned exists only on the std String type; the tristr
// alias shadows the bare name. LogRing stores std Strings.
let message = std::string::String::from_utf8_lossy_owned(line);
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
@@ -265,12 +268,12 @@ impl CoreManager {
let _ = terminated.send(());
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
let message: String = if let Some(code) = term.code {
format!("Process terminated with code: {}", code).into()
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
format!("Process terminated by signal: {}", signal).into()
} else {
String::from("Process terminated")
"Process terminated".into()
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
+3 -6
View File
@@ -1,12 +1,9 @@
use super::NoticeStatus;
use crate::{APP_HANDLE, singleton};
use smartstring::alias::String;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::AppHandle;
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
use tristr::alias::String;
use super::notification::{FrontendEvent, NotificationSystem};
@@ -73,7 +70,7 @@ impl Handle {
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
}
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
pub fn notice(status: NoticeStatus, message: impl Into<String>) {
Self::send_event(FrontendEvent::NoticeMessage {
status,
message: message.into(),
+9 -13
View File
@@ -3,11 +3,10 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
#[cfg(test)]
use serde_json::json;
use smartstring::alias::String;
use std::sync::Arc;
use std::sync::LazyLock;
use std::{collections::HashMap, future::Future};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
use tristr::alias::String;
#[derive(Debug, Clone)]
pub enum FrontendEvent<'a> {
@@ -17,7 +16,7 @@ pub enum FrontendEvent<'a> {
RefreshProxyConfig,
NoticeMessage {
status: NoticeStatus,
message: Arc<str>,
message: String,
},
ProfileChanged {
current_profile_id: &'a String,
@@ -77,10 +76,8 @@ impl FailedOperation {
#[serde(rename_all = "camelCase")]
pub struct PendingFailure {
/// Stable code used as the table key.
#[specta(type = std::string::String)]
pub code: String,
/// Full diagnostic context chain.
#[specta(type = std::string::String)]
pub detail: String,
pub operation: FailedOperation,
/// Monotonic identity for repeated failures under the same code.
@@ -247,7 +244,6 @@ static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::de
#[derive(Debug, Default, Clone, serde::Serialize, specta::Type)]
pub struct SidecarFailureSnapshot {
pub revision: u64,
#[specta(type = Option<std::string::String>)]
pub detail: Option<String>,
}
@@ -391,7 +387,7 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
(
FrontendEvent::NoticeMessage {
status: NoticeStatus::Info,
message: Arc::from("ok"),
message: String::from("ok"),
},
"verge://notice-message",
json!(["info", "ok"]),
@@ -456,16 +452,16 @@ frontend_events! {
refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => ();
refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => ();
refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => ();
notice: (NoticeStatus, std::string::String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.to_string());
profile_changed: std::string::String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.to_string();
timer_updated: std::string::String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.to_string();
profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
notice: (NoticeStatus, String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.clone());
profile_changed: String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.clone();
timer_updated: String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.clone();
profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.clone() };
profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.clone() };
run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state;
pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => ();
}
#[derive(serde::Serialize, specta::Type)]
pub struct ProfileUpdatePayload {
pub uid: std::string::String,
pub uid: String,
}
+11 -7
View File
@@ -2,6 +2,8 @@ use crate::utils::dirs;
use anyhow::{Context as _, Result};
use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity};
use std::path::Path;
#[cfg(windows)]
use tristr::alias::String;
pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> {
current_owner_credentials_for_root(&dirs::app_home_dir()?)
@@ -39,16 +41,16 @@ pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result<Owne
}
#[cfg(windows)]
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<String>)> {
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<std::string::String>)> {
let sid = windows_owner::current_sid()?;
let token = windows_owner::load_or_create_token(app_data_root, &sid)?;
Ok((OwnerIdentity::Windows { sid }, Some(token)))
Ok((OwnerIdentity::Windows { sid: sid.into() }, Some(token.into())))
}
#[cfg(windows)]
fn windows_current_identity() -> Result<OwnerIdentity> {
Ok(OwnerIdentity::Windows {
sid: windows_owner::current_sid()?,
sid: windows_owner::current_sid()?.into(),
})
}
@@ -79,7 +81,7 @@ pub(crate) fn repair_app_data_root_owner(app_data_root: &Path) -> Result<()> {
#[cfg(windows)]
pub(crate) fn current_user_pipe_sddl() -> Result<String> {
let sid = windows_owner::current_sid()?;
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)"))
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)").into())
}
#[cfg(windows)]
@@ -644,17 +646,19 @@ mod windows_owner {
}
let value = LocalWideString(value);
let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count();
String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
// from_utf16 exists only on the std String type; the tristr alias shadows the bare name.
std::string::String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
.context("process SID is not valid UTF-16")
.map(Into::into)
}
fn encode_token(token: &[u8; TOKEN_BYTES]) -> String {
let mut encoded = String::with_capacity(TOKEN_BYTES * 2);
let mut encoded = std::string::String::with_capacity(TOKEN_BYTES * 2);
for byte in token {
use std::fmt::Write as _;
let _ = write!(encoded, "{byte:02x}");
}
encoded
encoded.into()
}
fn wide_path(path: &Path) -> Result<Vec<u16>> {
+20 -10
View File
@@ -6,7 +6,7 @@ use crate::{
notification::{self, FailedOperation},
runstate::RUN_STATE,
service,
sysopt::{Sysopt, format_bypass},
sysopt::Sysopt,
},
process::AsyncHandler,
utils::{
@@ -24,6 +24,7 @@ use std::{
time::Duration,
};
use tokio::{sync::Mutex, time::timeout};
use tristr::alias::String;
const CLEAR_ATTEMPTS: u32 = 3;
const CLEAR_RETRY_DELAY: Duration = Duration::from_millis(100);
@@ -204,6 +205,8 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
}
const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
@@ -318,14 +321,21 @@ fn truncate_utf8(value: &mut String, max_bytes: usize) {
while !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
let truncated = value.as_str()[..boundary].into();
*value = truncated;
}
fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into();
let mut bypass = if custom.is_empty() {
MACOS_DEFAULT_BYPASS.into()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}").into()
} else {
custom.into()
};
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass)
}
@@ -345,7 +355,7 @@ fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Resul
Ok(MacosProxyConfig::Global {
host: LOOPBACK_HOST.to_owned(),
port: mixed_port,
bypass: service_bypass(verge)?,
bypass: service_bypass(verge)?.into(),
})
}
}
@@ -360,7 +370,7 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome
// Direct fallback satisfies a disable request.
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
ProxyApplyOutcome::DirectFallback { message } => {
Err(SysproxyFailure::DirectFallback { detail: message }.into())
Err(SysproxyFailure::DirectFallback { detail: message.into() }.into())
}
}
}
@@ -636,7 +646,7 @@ const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3;
async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped {
detail: format!("{error:#}"),
detail: format!("{error:#}").into(),
});
// Do not publish a late failure from a replaced guard.
let recorded = SERVICE_PROXY_OPERATIONS
@@ -1098,7 +1108,7 @@ mod tests {
let requested = MacosProxyConfig::Global {
host: "127.0.0.1".to_owned(),
port: 7890,
bypass: String::new(),
bypass: std::string::String::new(),
};
let error = service_apply_result(
@@ -1138,8 +1148,8 @@ mod tests {
let events = Mutex::new(Vec::new());
super::settle_table(
effect,
|asked| events.lock().push(format!("retire:{asked:?}")),
|operation, _| events.lock().push(format!("file:{operation:?}")),
|asked| events.lock().push(format!("retire:{asked:?}").into()),
|operation, _| events.lock().push(format!("file:{operation:?}").into()),
);
events.into_inner()
}
@@ -1268,7 +1278,7 @@ mod tests {
let combined = rollback_failure(
mappable(),
anyhow::anyhow!("the service could not set it").context(SysproxyFailure::DirectFallback {
detail: "fell back to direct".to_owned(),
detail: "fell back to direct".into(),
}),
);
+23 -14
View File
@@ -4,6 +4,7 @@ use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
use tristr::alias::String;
pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>,
@@ -270,7 +271,7 @@ impl ProxyViewBuilder {
let global = core_groups
.remove("GLOBAL")
.map(|proxy| build_group("GLOBAL".to_owned(), proxy, &resolver));
.map(|proxy| build_group(String::from("GLOBAL"), proxy, &resolver));
let (groups, order_source) = build_ordered_groups(core_groups, runtime_group_order, &resolver);
let direct = core_node_ids.get("DIRECT").cloned();
let standalone = build_standalone(&core_node_ids);
@@ -294,6 +295,7 @@ fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String
let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
let name = String::from(name);
if proxy.all.is_some() {
groups.insert(name, proxy);
} else {
@@ -311,7 +313,7 @@ fn build_core_records(
let mut ids = BTreeMap::new();
for (index, (name, proxy)) in core_nodes.into_iter().enumerate() {
let record_id = format!("c:{index}");
let record_id = String::from(format!("c:{index}"));
ids.insert(name.clone(), record_id.clone());
records.insert(
record_id.clone(),
@@ -332,7 +334,13 @@ fn build_provider_records(
records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
let providers = providers
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
.map(|providers| {
providers
.providers
.into_iter()
.map(|(name, provider)| (String::from(name), provider))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let mut views = Vec::new();
let mut candidates = BTreeMap::new();
@@ -355,9 +363,9 @@ fn build_provider_records(
let mut proxy_record_ids = Vec::new();
for (member_index, proxy) in proxies.into_iter().enumerate() {
let record_id = format!("p:{provider_index}:{member_index}");
let proxy_name = proxy.name.clone();
let node_name = proxy.name.clone();
let record_id = String::from(format!("p:{provider_index}:{member_index}"));
let proxy_name = String::from(proxy.name.as_str());
let node_name = String::from(proxy.name.as_str());
candidates
.entry(proxy_name.clone())
.or_insert_with(Vec::new)
@@ -380,7 +388,7 @@ fn build_provider_records(
views.push(ProxyProviderView {
name: provider_name,
vehicle_type,
updated_at,
updated_at: updated_at.map(String::from),
subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo {
upload: subscription_info.upload,
download: subscription_info.download,
@@ -417,6 +425,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
.unwrap_or_default()
.into_iter()
.map(|member| {
let member = String::from(member);
let (occurrence, scoped) = seen
.entry(member.clone())
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
@@ -429,11 +438,11 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
ProxyGroupView {
name,
proxy_type,
now,
fixed,
now: now.map(String::from),
fixed: fixed.map(String::from),
hidden,
icon,
test_url,
icon: icon.map(String::from),
test_url: test_url.map(String::from),
history,
capabilities: ProxyCapabilities {
udp,
@@ -467,10 +476,10 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name,
proxy_type,
history,
id,
id: id.map(String::from),
hidden,
icon,
test_url,
icon: icon.map(String::from),
test_url: test_url.map(String::from),
capabilities: ProxyCapabilities {
udp,
xudp,
+8 -7
View File
@@ -64,7 +64,7 @@ impl RunStateEnv for RealEnv {
Ok(ServiceVersionReply {
core,
code: response.code,
message: response.message,
message: response.message.into(),
protocol: response.data,
})
}
@@ -123,6 +123,7 @@ mod fake {
use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply};
use crate::core::manager::RunningMode;
use tristr::alias::String;
/// A fail-closed scripted machine that records outbound effects.
#[derive(Debug)]
@@ -163,7 +164,7 @@ mod fake {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: Some(ProtocolInfo::current()),
}))
}
@@ -173,7 +174,7 @@ mod fake {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: None,
}))
}
@@ -181,12 +182,12 @@ mod fake {
#[must_use]
pub fn service_unreachable(self) -> Self {
self.with_evidence(true)
.always_replying(Err("ipc transport refused".to_owned()))
.always_replying(Err("ipc transport refused".into()))
}
#[must_use]
pub fn evidence_unavailable(mut self) -> Self {
self.evidence = Err("registry probe failed".to_owned());
self.evidence = Err("registry probe failed".into());
self
}
@@ -236,7 +237,7 @@ mod fake {
#[must_use]
pub fn privileged_operations_fail(self, reason: &str) -> Self {
*self.privileged_outcome.lock() = Err(reason.to_owned());
*self.privileged_outcome.lock() = Err(reason.into());
self
}
@@ -256,7 +257,7 @@ mod fake {
replies
.first()
.cloned()
.unwrap_or_else(|| Err("no service configured".to_owned()))
.unwrap_or_else(|| Err("no service configured".into()))
};
reply.map_err(|error| anyhow!(error))
}
+1
View File
@@ -2,6 +2,7 @@
//! Core lifecycle remains in `CoreManager`.
use crate::core::manager::RunningMode;
use tristr::alias::String;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ServiceHealth {
+5 -5
View File
@@ -173,7 +173,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
Type::Service,
"failed to inspect trusted service evidence: {error:#}"
);
return ServiceHealth::Unavailable(format!("service detection failed: {error:#}"));
return ServiceHealth::Unavailable(format!("service detection failed: {error:#}").into());
}
};
@@ -331,7 +331,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
#[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
pub fn allow_sidecar_after_service_refusal(&self, reason: tristr::alias::String) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending");
@@ -496,7 +496,7 @@ mod tests {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
}
}
@@ -713,7 +713,7 @@ mod tests {
for health in [
ServiceHealth::NotInstalled,
ServiceHealth::VersionMismatch,
ServiceHealth::Unavailable("boom".to_owned()),
ServiceHealth::Unavailable("boom".into()),
] {
let store = with_env(FakeEnv::new());
store.observe(health.clone());
@@ -858,7 +858,7 @@ mod tests {
#[tokio::test]
async fn awaiting_readiness_retries_a_silent_service_then_accepts_its_reply() {
let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".to_owned()), Ok(ready_reply())]));
let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".into()), Ok(ready_reply())]));
let state = store
.await_ready(2, Duration::ZERO)
+16 -12
View File
@@ -9,6 +9,7 @@ use clash_verge_service_ipc::{
};
use super::health::ServiceHealth;
use tristr::alias::String;
/// What the Service replied to a protocol-version query.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -50,7 +51,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}").into())
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
@@ -77,9 +78,12 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
reply.code, reply.message, client.epoch
)
};
ServiceVersionCheck::NeedsReinstall(format!(
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
))
ServiceVersionCheck::NeedsReinstall(
format!(
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
)
.into(),
)
}
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
@@ -104,7 +108,7 @@ pub fn classify_service_health(
match current {
CurrentServiceProbe::Ready => ServiceHealth::Ready,
CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.to_owned()),
CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.into()),
CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Missing => ServiceHealth::NotInstalled,
}
@@ -119,7 +123,7 @@ mod tests {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: Some(ProtocolInfo::current()),
}
}
@@ -154,7 +158,7 @@ mod tests {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: None,
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -172,7 +176,7 @@ mod tests {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 7,
message: "nope".to_owned(),
message: "nope".into(),
protocol: Some(ProtocolInfo::current()),
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -188,7 +192,7 @@ mod tests {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: Some(info),
};
assert!(matches!(
@@ -209,7 +213,7 @@ mod tests {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
message: "ok".into(),
protocol: Some(info),
};
assert!(matches!(
@@ -228,7 +232,7 @@ mod tests {
let reply = ServiceVersionReply {
core: None,
code,
message: "unsupported command".to_owned(),
message: "unsupported command".into(),
protocol: protocol.clone(),
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -250,7 +254,7 @@ mod tests {
fn an_unreachable_service_keeps_the_reason() {
assert_eq!(
classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"),
ServiceHealth::Unavailable("socket refused".to_owned())
ServiceHealth::Unavailable("socket refused".into())
);
}
+18 -86
View File
@@ -4,7 +4,8 @@ use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf, Prefix};
use std::path::{Component, Path, PathBuf};
use tristr::alias::String;
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
@@ -64,7 +65,7 @@ struct ProviderPathOwners {
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
let config_root = std::fs::canonicalize(config_root)?;
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).into()).collect();
for section in ["proxy-providers", "rule-providers"] {
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
continue;
@@ -89,7 +90,7 @@ pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root:
match (is_remote, provider.get("url").and_then(Value::as_str)) {
(true, Some(url)) => owners
.remote
.entry(url.to_owned())
.entry(url.into())
.or_default()
.push((section, name.clone())),
_ => owners.local = true,
@@ -105,7 +106,7 @@ pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root:
for (url, providers) in owners.remote {
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
for (section, name) in providers {
config[section][&name]["path"] = Value::String(replacement.clone());
config[section][&name]["path"] = Value::String(replacement.clone().into());
logging!(
warn,
Type::Config,
@@ -131,15 +132,16 @@ fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut Ha
let mut suffix = String::new();
let mut sequence = 0;
loop {
let candidate = path
let candidate: String = path
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
.to_string_lossy()
.replace('\\', "/");
.replace('\\', "/")
.into();
if reserved.insert(candidate.clone()) {
return candidate;
}
sequence += 1;
suffix = format!("-{sequence}");
suffix = format!("-{sequence}").into();
}
}
@@ -165,10 +167,9 @@ fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<Runtim
&mut assets,
&mut remote_providers,
)?;
collect_ssh_private_keys(&mut config, &config_root, &mut destinations, &mut assets);
for filename in GEO_ASSETS {
let source = config_root.join(filename);
if source.is_file() && destinations.insert((*filename).to_string()) {
if source.is_file() && destinations.insert((*filename).into()) {
assets.push(RuntimeAsset {
source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(),
destination: (*filename).to_string(),
@@ -229,9 +230,9 @@ fn collect_provider_assets(
}
remote_providers.push(RemoteProviderRef {
section,
name: name.as_str().unwrap_or_default().to_owned(),
name: name.as_str().unwrap_or_default().into(),
provider: RemoteProvider {
destination: destination.clone(),
destination: destination.clone().into(),
url,
},
});
@@ -260,86 +261,17 @@ fn collect_provider_assets(
if destinations.insert(destination.clone()) {
assets.push(RuntimeAsset {
source: source.to_string_lossy().into_owned(),
destination: destination.clone(),
destination: destination.clone().into(),
});
}
destination
}
};
provider.insert(Value::String("path".to_owned()), Value::String(destination));
provider.insert(Value::String("path".to_owned()), Value::String(destination.into()));
}
Ok(())
}
/// Stages file-based ssh keys, which mihomo resolves against the core home directory.
fn collect_ssh_private_keys(
config: &mut Value,
config_root: &Path,
destinations: &mut HashSet<String>,
assets: &mut Vec<RuntimeAsset>,
) {
let Some(config) = config.as_mapping_mut() else {
return;
};
// mihomo parses `payload` for every provider type, eagerly as fallback proxies for file and http.
let mut proxy_lists = Vec::new();
for (key, value) in config.iter_mut() {
match key.as_str() {
Some("proxies") => proxy_lists.extend(value.as_sequence_mut()),
Some("proxy-providers") => proxy_lists.extend(
value
.as_mapping_mut()
.into_iter()
.flat_map(Mapping::values_mut)
.filter_map(|provider| provider.get_mut("payload").and_then(Value::as_sequence_mut)),
),
_ => {}
}
}
for proxy in proxy_lists.into_iter().flatten().filter_map(Value::as_mapping_mut) {
if proxy.get("type").and_then(Value::as_str) != Some("ssh") {
continue;
}
// mihomo reads any value containing "PRIVATE KEY" as inline key material.
let Some(raw_path) = proxy
.get("private-key")
.and_then(Value::as_str)
.filter(|key| !key.is_empty() && !key.contains("PRIVATE KEY") && is_local_path(key))
else {
continue;
};
let Some(source) = local_provider_source(config_root, raw_path)
.ok()
.filter(|source| source.is_file())
else {
let name = proxy.get("name").and_then(Value::as_str).unwrap_or_default();
logging!(
warn,
Type::Config,
"ssh proxy {name:?} private key is unavailable below the config root; leaving it to the core"
);
continue;
};
// A root-level alias keeps the user's path away from service name rules and mihomo's inline-key test.
let source = source.to_string_lossy().into_owned();
let destination = allocate_provider_destination("ssh-private-key", &source, destinations);
assets.push(RuntimeAsset {
source,
destination: destination.clone(),
});
proxy.insert(Value::String("private-key".to_owned()), Value::String(destination));
}
}
/// Resolving a UNC or device path would authenticate to its server, so those stay with the core.
fn is_local_path(raw_path: &str) -> bool {
match Path::new(raw_path).components().next() {
Some(Component::Prefix(prefix)) => matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)),
_ => true,
}
}
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
let path = Path::new(raw_path);
let source = if path.is_absolute() {
@@ -409,7 +341,7 @@ fn normalized_destination(relative: &Path) -> Result<String> {
if destination.as_os_str().is_empty() {
bail!("local runtime provider destination is empty");
}
Ok(destination.to_string_lossy().replace('\\', "/"))
Ok(destination.to_string_lossy().replace('\\', "/").into())
}
#[cfg(test)]
@@ -455,12 +387,12 @@ mod tests {
for declared in readback {
assert!(bundle.remote_providers.contains(&declared.provider));
assert_eq!(
config[declared.section][&declared.name]["path"],
config[declared.section][declared.name.as_str()]["path"],
declared.provider.destination
);
assert_eq!(
config[declared.section][&declared.name]["url"],
original[declared.section][&declared.name]["url"]
config[declared.section][declared.name.as_str()]["url"],
original[declared.section][declared.name.as_str()]["url"]
);
}
std::fs::remove_dir_all(root)?;
+76 -138
View File
@@ -13,7 +13,7 @@ use crate::{
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
},
runtime_bundle::{GEO_ASSETS, RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray,
},
process::AsyncHandler,
@@ -36,6 +36,7 @@ use std::{
sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tristr::alias::String;
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
@@ -62,7 +63,7 @@ pub(crate) fn is_not_auto_started(reason: &str) -> bool {
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback(reason: &str) {
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.into());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
@@ -91,7 +92,7 @@ fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
return None;
};
let path = credentials.app_data_dir.replace('\'', r"'\''");
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
Some(format!("sudo chown -R {uid}:{gid} '{path}'").into())
}
/// Capabilities of the service session that owns the running Core.
@@ -101,7 +102,6 @@ struct ActiveServiceSession {
proof: OwnerSessionProof,
supports_runtime_staging: bool,
supports_runtime_file_read: bool,
supports_runtime_geo_read: bool,
}
fn generate_service_session_token() -> Result<String> {
@@ -133,13 +133,6 @@ pub(crate) fn active_service_supports_runtime_file_read() -> bool {
.is_some_and(|session| session.supports_runtime_file_read)
}
fn active_service_supports_runtime_geo_read() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session.supports_runtime_geo_read)
}
pub(crate) fn clear_active_service_session() {
ACTIVE_SERVICE_SESSION.lock().take();
}
@@ -148,7 +141,6 @@ pub(crate) fn clear_active_service_session() {
struct ServiceCapabilities {
runtime_staging: bool,
runtime_file_read: bool,
runtime_geo_read: bool,
}
impl ServiceCapabilities {
@@ -156,7 +148,6 @@ impl ServiceCapabilities {
Self {
runtime_staging: info.supports_runtime_staging(),
runtime_file_read: info.supports_runtime_file_read(),
runtime_geo_read: info.supports_runtime_geo_read(),
}
}
}
@@ -253,22 +244,24 @@ fn macos_service_install_markers() -> Vec<String> {
format!(
"/Library/LaunchDaemons/{}.plist",
clash_verge_service_ipc::MACOS_SERVICE_ID
),
)
.into(),
format!(
"/Library/PrivilegedHelperTools/{}.bundle",
clash_verge_service_ipc::MACOS_SERVICE_ID
),
)
.into(),
#[cfg(not(feature = "verge-dev"))]
"/Library/LaunchDaemons/io.github.clashverge.helper.plist".to_owned(),
"/Library/LaunchDaemons/io.github.clashverge.helper.plist".into(),
#[cfg(not(feature = "verge-dev"))]
"/Library/PrivilegedHelperTools/io.github.clashverge.helper".to_owned(),
"/Library/PrivilegedHelperTools/io.github.clashverge.helper".into(),
]
}
#[cfg(target_os = "macos")]
fn macos_service_install_marker_exists() -> std::io::Result<bool> {
for marker in macos_service_install_markers() {
if path_entry_exists_without_follow(Path::new(&marker))? {
if path_entry_exists_without_follow(Path::new(marker.as_str()))? {
return Ok(true);
}
}
@@ -345,7 +338,8 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
output.status
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim() != "not-found")
// from_utf8_lossy exists only on the std String type; the tristr alias shadows the bare name.
Ok(std::string::String::from_utf8_lossy(&output.stdout).trim() != "not-found")
}
#[cfg(target_os = "macos")]
@@ -574,12 +568,12 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
#[cfg(target_os = "macos")]
fn escape_osascript_double_quoted_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
value.replace('\\', "\\\\").replace('"', "\\\"").into()
}
#[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
format!("'{}'", value.replace('\'', r"'\''")).into()
}
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
@@ -603,76 +597,55 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
}
#[cfg(target_os = "windows")]
pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result<std::process::ExitStatus> {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _;
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => {
let mut command = RunasCommand::new(program);
if hide_window {
command.show(false);
}
command.status()?
}
_ => {
let mut command = StdCommand::new(program);
if hide_window {
// 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false).
command.creation_flags(0x08000000);
}
command.status()?
}
};
Ok(status)
}
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
Ok(uninstall_path)
}
fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> {
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
let status = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?,
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?,
};
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
let status = run_maybe_elevated(&uninstall_path, true)?;
ensure_uninstall_success(&status)
}
#[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()?
} else {
let result = StdCommand::new(&elevator)
let result = StdCommand::new(elevator.as_str())
.arg("--disable-internal-agent")
.arg(&uninstall_path)
.status()?;
@@ -697,7 +670,14 @@ fn uninstall_service() -> Result<()> {
status.code().unwrap_or(-1)
);
ensure_uninstall_success(&status)
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(target_os = "linux")]
@@ -712,12 +692,16 @@ fn linux_running_as_root() -> bool {
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let uninstall_path = macos_service_tool_path(&uninstall_path)?;
let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
let uninstall_shell: String = uninstall_path.to_string_lossy().into();
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
@@ -731,7 +715,14 @@ fn uninstall_service() -> Result<()> {
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
ensure_uninstall_success(&status)
if !status.success() {
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn install_service() -> Result<()> {
@@ -856,7 +847,7 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
if response.code > 0 {
return Ok(StageRequest::Refused {
code: response.code,
message: response.message,
message: response.message.into(),
});
}
response
@@ -892,7 +883,7 @@ fn record_service_start_refusal<E: RunStateEnv>(
if store.state().mode == crate::core::manager::RunningMode::NotRunning
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16
{
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
store.observe(ServiceHealth::Unavailable(refusal.to_string().into()));
}
refusal.into()
}
@@ -900,7 +891,7 @@ fn record_service_start_refusal<E: RunStateEnv>(
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
pub(super) fn record_residual_service(error: &anyhow::Error) {
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string().into()));
}
}
@@ -914,7 +905,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
let proposed_session_token = generate_service_session_token()?;
let request = StartClashRequest {
runtime,
proposed_session_token: proposed_session_token.clone(),
proposed_session_token: proposed_session_token.clone().into(),
macos_proxy: None,
};
@@ -952,8 +943,8 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
&RUN_STATE,
ServiceStartRefusal {
code: response.code,
core_path: request.runtime.core_path,
message: err_msg,
core_path: request.runtime.core_path.into(),
message: err_msg.into(),
},
));
}
@@ -965,11 +956,10 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
*ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession {
proof: OwnerSessionProof {
generation: result.session.generation,
token: proposed_session_token,
token: proposed_session_token.into(),
},
supports_runtime_staging: capabilities.runtime_staging,
supports_runtime_file_read: capabilities.runtime_file_read,
supports_runtime_geo_read: capabilities.runtime_geo_read,
});
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
@@ -1026,7 +1016,10 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
bail!(err_msg);
}
Ok(response.data.unwrap_or_default())
Ok(response
.data
.map(|logs| logs.into_iter().map(Into::into).collect())
.unwrap_or_default())
}
pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
@@ -1044,7 +1037,8 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
}
let encoded = response.data.context("服务未返回核心日志快照")?;
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
Ok(String::from_utf8_lossy_owned(content))
// from_utf8_lossy_owned exists only on the std String type; the tristr alias shadows the bare name.
Ok(std::string::String::from_utf8_lossy_owned(content).into())
}
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
@@ -1220,62 +1214,6 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
Ok(outcome)
}
/// Pulls geo databases the service core rewrote, because validation reads the app dir.
pub(crate) async fn sync_runtime_geo_by_service() {
if !active_service_supports_runtime_geo_read() {
return;
}
let (Ok(credentials), Ok(session), Ok(app_dir)) = (
current_owner_credentials(),
active_service_session(),
dirs::app_home_dir(),
) else {
return;
};
for asset in GEO_ASSETS {
if let Err(error) = sync_runtime_geo(&credentials, &session, asset, &app_dir.join(asset)).await {
logging!(warn, Type::Service, "failed to sync geo database {asset}: {error:#}");
}
}
}
async fn sync_runtime_geo(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
asset: &str,
target: &Path,
) -> Result<()> {
let Ok(local) = tokio::fs::metadata(target).await else {
return Ok(());
};
let Some((Some(_), _, Some(runtime_mtime_ns))) = read_chunk(credentials, session, asset, u64::MAX).await? else {
return Ok(());
};
// A newer local file is a change the next staging still has to copy into the runtime.
if UNIX_EPOCH + Duration::from_nanos(runtime_mtime_ns) <= local.modified()? {
return Ok(());
}
let (temp, file) = create_sync_temp(target).await?;
let result = async {
if let Fetch::Complete {
mtime_ns: Some(mtime_ns),
..
} = fetch_runtime_file(credentials, session, asset, file).await?
{
// Matching the runtime copy's time lets the service skip copying these bytes straight back.
std::fs::File::options()
.write(true)
.open(&temp)?
.set_modified(UNIX_EPOCH + Duration::from_nanos(mtime_ns))?;
tokio::fs::rename(&temp, target).await?;
}
anyhow::Ok(())
}
.await;
remove_temp(&temp).await;
result
}
// The on-disk YAML may describe a rejected apply; use the committed runtime.
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
let applied = runtime.data_arc();
@@ -1414,7 +1352,7 @@ async fn read_chunk(
}
Ok(Some(match response.data.context("服务未返回运行时文件")? {
RuntimeFileOutcome::Absent => (None, 0, None),
RuntimeFileOutcome::Chunk { hex, len, mtime_ns } => (Some(hex), len, mtime_ns),
RuntimeFileOutcome::Chunk { hex, len, mtime_ns } => (Some(hex.into()), len, mtime_ns),
}))
}
@@ -1426,11 +1364,11 @@ async fn provider_load_times() -> Result<ProviderLoadTimes> {
let proxies = proxies.map_err(|error| anyhow!("failed to query proxy providers: {error}"))?;
let mut loaded = ProviderLoadTimes::new();
for (name, provider) in rules.providers {
loaded.insert(("rule-providers", name), epoch_nanos(&provider.updated_at));
loaded.insert(("rule-providers", name.into()), epoch_nanos(&provider.updated_at));
}
for (name, provider) in proxies.providers {
loaded.insert(
("proxy-providers", name),
("proxy-providers", name.into()),
provider.updated_at.as_deref().and_then(epoch_nanos),
);
}
@@ -1656,7 +1594,7 @@ const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> O
fn mark_service_unavailable_after_owner_loss<E: RunStateEnv>(store: &RunStateStore<E>, reason: OwnerRecoveryReason) {
if matches!(reason, OwnerRecoveryReason::TransportFailure) {
store.observe(ServiceHealth::Unavailable(
"service control IPC unavailable after sustained transport failure".to_owned(),
"service control IPC unavailable after sustained transport failure".into(),
));
}
}
@@ -1917,7 +1855,7 @@ async fn wait_for_service_ipc() -> Result<()> {
}
Err(ReadyWaitError::Unreachable(error)) => {
tracing::Span::current().record("outcome", "unreachable");
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}")));
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}").into()));
Err(error).context(CONTEXT)
}
Err(ReadyWaitError::Rejected(error)) => {
@@ -2732,7 +2670,7 @@ mod tests {
let outcome = super::run_action_restoring_sidecar(&store, was_allowed, async {
store.perform(PendingAction::Install).await?;
// Simulate `wait_for_service_ipc` recording health before it fails.
store.observe(ServiceHealth::Unavailable("service never answered".to_owned()));
store.observe(ServiceHealth::Unavailable("service never answered".into()));
bail!("service IPC did not become available")
})
.await;
+17 -22
View File
@@ -8,7 +8,6 @@ use anyhow::Result;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use scopeguard::defer;
use smartstring::alias::String;
use std::{
sync::{
Arc,
@@ -18,6 +17,7 @@ use std::{
};
use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy};
use tokio::sync::Mutex as TokioMutex;
use tristr::alias::String;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProxyApplyStep {
@@ -241,19 +241,20 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
}
pub(crate) struct Sysopt {
// Owns the proxy target pair; every reader and writer goes through this lock.
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
update_lock: TokioMutex<()>,
guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>,
}
impl Default for Sysopt {
fn default() -> Self {
Self {
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
update_lock: TokioMutex::new(()),
guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false),
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
}
}
@@ -268,9 +269,10 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
#[cfg(any(target_os = "linux", target_os = "macos"))]
static BYPASS_SEPARATOR: &str = ",";
#[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
static DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
if custom_bypass.is_empty() {
DEFAULT_BYPASS.into()
} else if use_default {
@@ -359,7 +361,7 @@ impl Sysopt {
}
}
while self.access_guard().read().get_state().is_pendding() {
tokio::time::sleep(Duration::from_millis(10)).await;
tokio::task::yield_now().await;
}
// `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped()
@@ -407,21 +409,18 @@ impl Sysopt {
/// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition).
pub(crate) async fn wait_idle(&self) {
let _ = self.proxy_state_lock.lock().await;
let _ = self.update_lock.lock().await;
}
/// init the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
)]
pub(super) async fn update_sysproxy(&self) -> Result<()> {
let mut proxy_state = self.proxy_state_lock.lock().await;
let _lock = self.update_lock.lock().await;
let verge = Config::verge().await.latest_arc();
// Configured, not live: this runs while the Core is being started or restarted, and
// asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await;
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
@@ -432,7 +431,7 @@ impl Sysopt {
);
let (sys, auto, guard_type) = {
let (sys, auto) = &mut *proxy_state;
let (sys, auto) = &mut *self.inner_proxy.write();
sys.host = proxy_host.into();
sys.port = port;
sys.bypass = bypass.into();
@@ -529,10 +528,6 @@ impl Sysopt {
}
/// reset the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
)]
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self
.reset_sysproxy
@@ -544,13 +539,13 @@ impl Sysopt {
defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst);
}
let mut proxy_state = self.proxy_state_lock.lock().await;
let _lock = self.update_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await;
let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理
let (sys, auto) = {
let (sys, auto) = &mut *proxy_state;
let (sys, auto) = &mut *self.inner_proxy.write();
sys.enable = false;
auto.enable = false;
(sys.clone(), auto.clone())
@@ -682,8 +677,8 @@ mod tests {
(calls.into_inner(), result.is_ok())
}
fn failure_text(result: anyhow::Result<()>) -> std::string::String {
result.map_or_else(|error| format!("{error:#}"), |()| "no failure".to_owned())
fn failure_text(result: anyhow::Result<()>) -> String {
result.map_or_else(|error| format!("{error:#}").into(), |()| String::from("no failure"))
}
#[test]
+2 -2
View File
@@ -8,7 +8,6 @@ use crate::{
use anyhow::Result;
use clash_verge_logging::{Type, logging, logging_error};
use parking_lot::{Mutex, RwLock};
use smartstring::alias::String;
use std::{
collections::HashMap,
sync::{
@@ -23,6 +22,7 @@ use tokio::{
};
use tokio_stream::StreamExt as _;
use tokio_util::time::{DelayQueue, delay_queue::Key};
use tristr::alias::String;
enum TimerCommand {
Apply(HashMap<String, TaskSchedule>),
@@ -502,7 +502,6 @@ mod tests {
use super::{TaskSchedule, Timer, run_timer_profile_update_transition};
use crate::config::{PrfItem, PrfOption};
use parking_lot::Mutex;
use smartstring::alias::String;
use std::collections::HashMap;
use std::{
sync::{
@@ -515,6 +514,7 @@ mod tests {
use tokio::sync::Barrier;
use tokio_stream::StreamExt as _;
use tokio_util::time::DelayQueue;
use tristr::alias::String;
struct CancellationProbe {
cancelled: Arc<AtomicBool>,
+2 -3
View File
@@ -19,7 +19,6 @@ use tokio::fs;
use super::handle;
use anyhow::Result;
use smartstring::alias::String;
use std::borrow::Cow;
use std::collections::HashMap;
use std::time::Duration;
@@ -27,6 +26,7 @@ use tauri::{
AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
use tristr::alias::String;
mod menu_def;
#[cfg(target_os = "macos")]
@@ -636,8 +636,7 @@ async fn create_tray_menu(
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let include_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let proxy_group_order_map = runtime_proxy_groups_order;
+57 -30
View File
@@ -11,6 +11,7 @@ use std::{
};
use tauri_plugin_updater::{Update, UpdaterExt as _};
use tokio_util::sync::CancellationToken;
use tristr::alias::String;
pub struct SilentUpdater {
update_ready: AtomicBool,
@@ -18,14 +19,6 @@ pub struct SilentUpdater {
background_cancel: Mutex<Option<CancellationToken>>,
}
#[derive(Serialize, specta::Type)]
#[serde(rename_all = "snake_case")]
pub enum InstallUpdateOutcome {
Installed,
Cancelled,
UpdateChanged,
}
singleton!(SilentUpdater, SILENT_UPDATER);
impl SilentUpdater {
@@ -42,7 +35,7 @@ impl SilentUpdater {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, specta::Type)]
struct UpdateCacheMeta {
version: String,
downloaded_at: String,
@@ -63,8 +56,8 @@ impl SilentUpdater {
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
let meta = UpdateCacheMeta {
version: version.to_string(),
downloaded_at: Utc::now().to_rfc3339(),
version: version.into(),
downloaded_at: Utc::now().to_rfc3339().into(),
};
let meta_path = cache_dir.join("pending_update.json");
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)
@@ -109,11 +102,11 @@ impl SilentUpdater {
}
/// Returns the cached installer for `update`, deleting a cache that does not match it.
fn verified_cache(update: &Update) -> Option<Vec<u8>> {
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
let meta = Self::read_cache_meta().ok()?;
let bytes = if meta.version == update.version {
Self::read_cache_bytes().and_then(|bytes| {
update.verify(&bytes)?;
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
Ok(bytes)
})
} else {
@@ -135,6 +128,45 @@ impl SilentUpdater {
}
}
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
/// with the same rules `Update::download` applies.
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
use base64::Engine as _;
let decode = |value: &str| -> Result<String> {
// from_utf8 exists only on the std String type; the tristr alias shadows the bare name.
Ok(std::string::String::from_utf8(base64::engine::general_purpose::STANDARD.decode(value)?)?.into())
};
let config: tauri_plugin_updater::Config = serde_json::from_value(
app_handle
.config()
.plugins
.0
.get("updater")
.cloned()
.context("updater is not configured")?,
)?;
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
public_key
.verify(bytes, &signature, true)
.context("signature mismatch")?;
match signature
.trusted_comment()
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
{
Some(signed) => anyhow::ensure!(
signed.trim_start_matches('v') == version,
"signed for v{signed}, not v{version}"
),
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
}
Ok(())
}
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false;
@@ -359,7 +391,7 @@ impl SilentUpdater {
}
};
let Some(bytes) = Self::verified_cache(&update) else {
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
return false;
};
@@ -392,13 +424,13 @@ impl SilentUpdater {
}
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
/// A changed offer must be confirmed again before downloading or installing it.
/// `false` when cancelled before the installer starts.
pub async fn install_update(
&self,
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<InstallUpdateOutcome> {
) -> Result<bool> {
let cancel = CancellationToken::new();
*self.manual_cancel.lock() = Some(cancel.clone());
let prepared = tokio::select! {
@@ -408,36 +440,31 @@ impl SilentUpdater {
// `cancel_download` cancels under the lock, so once the token is taken back its state is
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
self.manual_cancel.lock().take();
let Some(prepared) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
logging!(info, Type::System, "Update v{version} cancelled");
return Ok(InstallUpdateOutcome::Cancelled);
};
let Some((update, bytes)) = prepared else {
return Ok(InstallUpdateOutcome::UpdateChanged);
return Ok(false);
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
Ok(true)
}
async fn prepare_install(
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<Option<(Update, Vec<u8>)>> {
let Some(update) = Self::check(app_handle)
) -> Result<(Update, Vec<u8>)> {
let update = Self::check(app_handle)
.await?
.filter(|update| update.version == version)
else {
return Ok(None);
};
let bytes = match Self::verified_cache(&update) {
.with_context(|| format!("v{version} is no longer offered"))?;
let bytes = match Self::verified_cache(app_handle, &update) {
Some(bytes) => bytes,
None => download(&update, on_event).await?,
};
Ok(Some((update, bytes)))
Ok((update, bytes))
}
pub fn cancel_download(&self) {
@@ -614,7 +641,7 @@ impl SilentUpdater {
return Ok(());
}
if Self::verified_cache(&update).is_some() {
if Self::verified_cache(app_handle, &update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached");
} else {
logging!(info, Type::System, "Silent updater: downloading v{version}...");
+3 -12
View File
@@ -1,7 +1,6 @@
use anyhow::Result;
use scopeguard::defer;
use serde::Serialize;
use smartstring::alias::String;
use std::{
fmt,
hash::{DefaultHasher, Hash as _, Hasher as _},
@@ -10,6 +9,7 @@ use std::{
};
use tauri_plugin_shell::ShellExt as _;
use tokio::fs;
use tristr::alias::String;
use crate::config::Config;
use crate::core::handle;
@@ -114,14 +114,8 @@ impl fmt::Display for ValidationSkipReason {
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ValidationOutcome {
Valid,
Invalid {
kind: ValidationErrorKind,
#[specta(type = std::string::String)]
message: String,
},
Skipped {
reason: ValidationSkipReason,
},
Invalid { kind: ValidationErrorKind, message: String },
Skipped { reason: ValidationSkipReason },
Busy,
}
@@ -324,7 +318,6 @@ impl CoreConfigValidator {
return Self::validate_file_syntax_outcome(config_path).await;
}
crate::core::service::sync_runtime_geo_by_service().await;
let is_script = match Self::is_script_file(config_path).await {
Ok(result) => result,
Err(err) => {
@@ -426,8 +419,6 @@ impl CoreConfigValidator {
}
logging!(info, Type::Validate, "生成临时配置文件用于验证");
// The test run reads the app dir, which never sees updates made by a service core.
crate::core::service::sync_runtime_geo_by_service().await;
let fingerprint = validation_fingerprint(yaml).await;
if *LAST_VALIDATED.lock() == Some(fingerprint) {
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
+11 -2
View File
@@ -1,7 +1,10 @@
#![cfg(target_os = "windows")]
use crate::{core::service, utils::dirs};
use crate::utils::dirs;
use anyhow::{Result, bail};
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::process::Command as StdCommand;
pub fn invoke_uwptools() -> Result<()> {
let resource_dir = dirs::app_resources_dir()?;
@@ -11,7 +14,13 @@ pub fn invoke_uwptools() -> Result<()> {
bail!("enableLoopback exe not found");
}
service::run_maybe_elevated(&tool_path, false)?;
let token = Token::with_current_process()?;
let level = token.privilege_level()?;
match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?,
_ => StdCommand::new(tool_path).status()?,
};
Ok(())
}
@@ -0,0 +1,8 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (config.mode === 'script') {
config.mode = 'rule'
}
return config
}
@@ -0,0 +1,12 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => {
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]
}
})
}
return config
}

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