Compare commits

..
Author SHA1 Message Date
Tunglies d1e3f09f17 perf(tray): batch menu rebuild into a single dispatch
Every menu API call runs on the main thread; invoked from a worker,
each call pays a blocking round-trip there and back (channel, event
loop hop, recv). Rebuilding the tray menu with proxy groups enabled
issued one round-trip per menu item — 200+ for a typical subscription —
and the per-call wait grows whenever the event loop is busy with
webview work.

Gather the async inputs (verge/clash/profiles snapshots, proxies and
runtime group order) on the caller's task, then build the menu and
attach it in a single run_on_main_thread closure where every item
constructs inline. Measured on a standalone tauri bench (5 groups x
40 nodes): a rebuild drops from ~6.2ms to ~1.6ms on an idle event
loop and from ~11.4ms to ~1.6ms with the loop busy (8ms of work per
16ms frame), with ~450 fewer allocations (~155KB) per rebuild.
2026-10-21 15:44:39 +08:00
157 changed files with 3471 additions and 3292 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "26.13.0", "version": "24.22.0",
"pnpmVersion": "12.16.1" "pnpmVersion": "12.14.0"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1", "version": "1.99.1",
-33
View File
@@ -1,33 +0,0 @@
# Issue Guidelines / Issue 提交指引
Use these suggestions to make reports easier to assess. State what you observed
and what you could not verify; unknown details are fine.
以下建议有助于维护者评估问题。请说明实际观察到的现象和无法确认的部分;未知信息可以留空。
- Search existing issues by symptom, conditions, and outcome. Add new evidence
to an existing report when it describes the same problem.
按现象、条件和结果搜索已有 Issue。若描述的是同一问题,请在原 Issue 中补充新证据。
- Keep each issue to one distinct user problem or requested outcome. Split
unrelated findings instead of bundling an audit or checklist.
每个 Issue 聚焦一个独立的问题或需求结果;无关发现请拆开,不要把审计清单打包成一个 Issue。
- For bugs, include the version, operating system, steps, and actual versus
expected behavior when known. A root-cause diagnosis is not required. Share
only relevant logs and redact tokens or personal information.
反馈 Bug 时,尽可能提供版本、操作系统、复现步骤及实际与预期行为。无需先判断根因;只提供相关日志,并遮盖令牌和个人信息。
- For feature requests, describe the user task, current limitation, desired
outcome, and affected platforms. An implementation proposal is optional.
提交功能请求时,请描述用户要完成的任务、当前限制、期望结果和适用平台;实现方案不是必填项。
- Separate observations from guesses. An AI or scanner finding is a hypothesis
until its affected configuration, concrete impact, and supporting evidence
are stated; do not present a list of theoretical risks as confirmed bugs.
区分观察结果和推测。AI 或扫描器的发现应先视为假设;请说明受影响的配置、具体影响和支持证据,不要把理论风险清单写成已确认的 Bug。
- Do not post secrets, personal data, or sensitive exploit details in a public
issue. Ask maintainers how to share sensitive security information safely.
请勿在公开 Issue 中发布密钥、个人信息或敏感利用细节;如涉及安全敏感信息,请先询问维护者如何安全提供。
- AI assistance is welcome; human contributors do not need to disclose it. If
an AI agent or automation creates or co-authors an issue, add a final body line
such as `Assisted by: GPT-5.6 High`. Omit effort when the runtime does not
report it. Disclosure is transparency only, not evidence for or against issue
quality.
欢迎使用 AI 辅助,人工提交者无需披露。如果 Issue 由 AI Agent 或自动化创建或共同撰写,请在正文末尾添加披露行,例如 `Assisted by: GPT-5.6 High`。运行环境未提供 effort 时可省略。披露仅用于提高透明度,不作为 Issue 质量的正面或负面证据。
-4
View File
@@ -10,8 +10,6 @@ body:
value: | value: |
## 在提交问题之前,请确认以下事项: ## 在提交问题之前,请确认以下事项:
提交前可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),说明可观察到的问题和证据;不确定的原因请标为猜测。
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 以及 [常见问题](https://clash-verge-rev.github.io/faq/windows.html) 1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 以及 [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论 2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索 3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
@@ -21,8 +19,6 @@ body:
## Before submitting the issue, please make sure of the following checklist: ## Before submitting the issue, please make sure of the following checklist:
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for how to describe observed behavior and evidence; mark uncertain causes as hypotheses.
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) and [FAQ](https://clash-verge-rev.github.io/faq/windows.html) 1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) and [FAQ](https://clash-verge-rev.github.io/faq/windows.html)
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue 2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search 3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
@@ -9,16 +9,12 @@ body:
attributes: attributes:
value: | value: |
## 在提交问题之前,请确认以下事项: ## 在提交问题之前,请确认以下事项:
可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),描述用户任务、当前限制和期望结果。
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能 1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论 2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索 3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现 4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭 5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
## Before submitting the issue, please make sure of the following checklist: ## Before submitting the issue, please make sure of the following checklist:
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for describing the user task, current limitation, and desired outcome.
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions 1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue 2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search 3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
+1 -7
View File
@@ -1,12 +1,6 @@
## Problem ## Problem
What breaks or what is missing. Product bug fixes and features require a What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
pre-existing issue; link it here (`Fixes #123` only when this PR fully resolves
it). Maintenance work (such as chore, perf, docs, refactor, test, CI/build,
dependency, style, routine localization, or release changes) does not require
an issue link; link one when it defines the scope. For reverts, link the PR or
commit being reverted. For security-sensitive fixes that cannot be tracked
publicly, follow maintainer-approved private tracking.
## Approach ## Approach
+5 -5
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -185,7 +185,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -282,7 +282,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -426,7 +426,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -524,7 +524,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.0
name: Install pnpm 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 - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
+104 -14
View File
@@ -2,23 +2,11 @@ name: Frontend Check
on: on:
pull_request: 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: workflow_dispatch:
permissions: permissions:
contents: read contents: read
pull-requests: read
env: env:
HUSKY: 0 HUSKY: 0
@@ -29,17 +17,46 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - 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 - name: Install pnpm
if: steps.check_frontend.outputs.frontend == 'true'
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: '26.13.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Restore pnpm cache - name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
@@ -48,21 +65,94 @@ jobs:
pnpm-shared-stable-${{ runner.os }}- pnpm-shared-stable-${{ runner.os }}-
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
if: steps.check_frontend.outputs.frontend == 'true'
- name: Run Prettier check - name: Run Prettier check
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm format:check run: pnpm format:check
- name: Run ESLint - name: Run ESLint
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm lint run: pnpm lint
- name: Run TypeScript typecheck - name: Run TypeScript typecheck
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck run: pnpm typecheck
- name: Run tests - name: Run tests
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm test run: pnpm test
- name: Run Knip - name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services) - 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 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.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true'
run: pnpm gen:contract --check
+23 -9
View File
@@ -2,14 +2,6 @@ name: Clippy Lint
on: on:
pull_request: pull_request:
paths:
- "src-tauri/**"
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/config.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch: workflow_dispatch:
env: env:
HUSKY: 0 HUSKY: 0
@@ -31,6 +23,28 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: 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 - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -65,7 +79,7 @@ jobs:
run: cargo clippy-all run: cargo clippy-all
- name: Run Windows security tests - 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 working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }} 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"
+76 -123
View File
@@ -1,9 +1,6 @@
name: Release Build name: Release Build
on: on:
pull_request:
types: [edited]
branches: [main]
push: push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags: tags:
@@ -14,59 +11,75 @@ env:
RUST_BACKTRACE: short RUST_BACKTRACE: short
HUSKY: 0 HUSKY: 0
concurrency: concurrency:
group: release-publication group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: false cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
prepare_release: check_tag_version:
name: Approve Snapshot and Prepare Draft Release name: Check Release Tag and package.json Version Consistency
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')) }}
runs-on: ubuntu-latest 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: steps:
- name: Checkout trusted release tooling - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
ref: refs/heads/dev
fetch-depth: 0 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 - name: Check if tag is from main branch
id: draft run: |
env: TAG_REF="${GITHUB_REF##*/}"
RELEASE_TAG: ${{ steps.source.outputs.release_tag }} echo "Checking if tag $TAG_REF is from main branch..."
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: node scripts-workflow/release.mjs prepare-draft 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: update_tag:
name: Publish Completed Release name: Update tag
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
outputs:
source_sha: ${{ needs.prepare_release.outputs.source_sha }}
channel: ${{ needs.prepare_release.outputs.channel }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
[ [
@@ -78,8 +91,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Group changelog platforms - name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh run: bash ./scripts-workflow/group_platforms.sh
@@ -92,7 +103,7 @@ jobs:
- name: Set Env - name: Set Env
run: | run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
TAG_REF="$RELEASE_TAG" TAG_REF="${GITHUB_REF##*/}"
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
VERSION=$(echo "$TAG_REF" | sed 's/^v//') VERSION=$(echo "$TAG_REF" | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -138,16 +149,10 @@ jobs:
Created at ${{ env.BUILDTIME }}. Created at ${{ env.BUILDTIME }}.
EOF 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 - name: Publish Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }} PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
run: | run: |
release_id="${{ needs.prepare_release.outputs.release_id }}" release_id="${{ needs.prepare_release.outputs.release_id }}"
@@ -176,8 +181,6 @@ jobs:
release: release:
name: Release Build name: Release Build
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -197,8 +200,6 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -229,7 +230,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -248,6 +249,7 @@ jobs:
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build - name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v1.0.0 uses: tauri-apps/tauri-action@v1.0.0
env: env:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
@@ -262,14 +264,11 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
releaseId: ${{ needs.prepare_release.outputs.release_id }} releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ env.RELEASE_TAG }} tagName: ${{ github.ref_name }}
releaseBody: 'Draft release, will be updated later.' 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 tauriScript: pnpm
args: --target ${{ matrix.target }} args: --target ${{ matrix.target }}
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }} includeUpdaterJson: true
- name: Attest Windows bundles - name: Attest Windows bundles
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
@@ -294,8 +293,6 @@ jobs:
release-for-linux-arm: release-for-linux-arm:
name: Release Build for Linux ARM name: Release Build for Linux ARM
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -310,8 +307,6 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -336,7 +331,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -425,7 +420,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release upload "$RELEASE_TAG" \ gh release upload "${{ github.ref_name }}" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \ target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \ target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber --clobber
@@ -433,8 +428,6 @@ jobs:
release-for-fixed-webview2: release-for-fixed-webview2:
name: Release Build for Fixed WebView2 name: Release Build for Fixed WebView2
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -449,8 +442,6 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -475,7 +466,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -541,55 +532,22 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName $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 } 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: release-update:
if: ${{ needs.update_tag.outputs.channel == 'stable' }} if: ${{ !contains(github.ref_name, '-rc') }}
name: Release Update name: Release Update
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag] needs: [update_tag]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -605,19 +563,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-update-for-fixed-webview2: release-update-for-fixed-webview2:
if: ${{ needs.update_tag.outputs.channel == 'stable' }} if: ${{ !contains(github.ref_name, '-rc') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag] needs: [update_tag]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -633,7 +589,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget: submit-to-winget:
if: ${{ needs.update_tag.outputs.channel == 'stable' }} if: ${{ !contains(github.ref_name, '-rc') }}
name: Submit to Winget name: Submit to Winget
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag, release-update] needs: [update_tag, release-update]
@@ -641,7 +597,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
ref: ${{ needs.update_tag.outputs.source_sha }}
fetch-depth: 0 fetch-depth: 0
- name: Get Version - name: Get Version
run: | run: |
@@ -658,7 +613,7 @@ jobs:
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}
notify-telegram: notify-telegram:
if: ${{ needs.update_tag.outputs.channel == 'stable' }} if: ${{ !contains(github.ref_name, '-rc') }}
name: Notify Telegram name: Notify Telegram
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
@@ -671,8 +626,6 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Group changelog platforms - name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh run: bash ./scripts-workflow/group_platforms.sh
@@ -685,7 +638,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+15 -6
View File
@@ -6,12 +6,6 @@ name: Check Formatting
on: on:
pull_request: pull_request:
paths:
- "src-tauri/**"
- "**/*.rs"
- "rustfmt.toml"
- "rust-toolchain.toml"
- ".github/workflows/rustfmt.yml"
permissions: permissions:
contents: read contents: read
@@ -25,10 +19,25 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - 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 - name: install Rust stable and rustfmt
if: steps.check_rust.outputs.rust == 'true'
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt components: rustfmt
- name: run cargo fmt - name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '26.13.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 26.13.0 nodejs 24.22.0
pnpm 12.16.1 pnpm 12.14.0
rust 1.99.1 rust 1.99.1
+21 -25
View File
@@ -18,25 +18,22 @@ These rules apply to every change, whether human- or agent-authored. They match
the ownership evidence the AI-slop review evaluates (see the ownership evidence the AI-slop review evaluates (see
[`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)). [`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)).
1. **Issue first.** Product bug fixes and features require a pre-existing issue; 1. **Issue first.** Non-trivial changes require a pre-existing issue describing
self-scoped maintenance work does not. Follow the PR template for linkage and the problem. If none exists, ask the maintainers to open or approve one
revert references. If a required issue is missing, ask maintainers to open or before implementing.
approve one before implementing. For security-sensitive fixes that cannot be
tracked publicly, follow maintainer-approved private tracking.
2. **Scope discipline.** Every changed file must be justifiable from the linked 2. **Scope discipline.** Every changed file must be justifiable from the linked
issue or the stated PR scope. No drive-by refactors, renames, formatting issue. No drive-by refactors, renames, formatting churn, or dependency bumps
churn, or dependency bumps unrelated to the work. unrelated to the problem being fixed.
3. **Author accountability.** AI assistance is welcome, but the contributor owns 3. **Author accountability.** AI assistance is welcome, but the contributor owns
the result: understand the change, describe the problem and approach in your the result: understand the change, describe the problem and approach in your
own words, and verify the change against the reported behavior before own words, and verify the change against the reported behavior before
submitting. submitting.
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or 4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
speculative defensive code unless the linked issue or stated task demands speculative defensive code unless the linked issue demands them. When a test
them. When a test is genuinely necessary — it reproduces the reported is genuinely necessary — it reproduces the reported regression or guards
regression or guards behavior whose breakage would otherwise go unnoticed — behavior whose breakage would otherwise go unnoticed — keep it minimal and
keep it minimal and state in the PR body why it is needed. Bulk test files state in the PR body why it is needed. Bulk test files and defensive
and defensive programming for hypothetical failure modes are PR bloat, not programming for hypothetical failure modes are PR bloat, not rigor.
rigor.
5. **Comments state constraints, not narration.** Write a comment only for a 5. **Comments state constraints, not narration.** Write a comment only for a
non-obvious constraint the code cannot express; never restate what the code non-obvious constraint the code cannot express; never restate what the code
does. does.
@@ -47,12 +44,13 @@ the ownership evidence the AI-slop review evaluates (see
real evidence instead: reproduction steps, failure output, targeted tests. real evidence instead: reproduction steps, failure output, targeted tests.
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment 8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
density. Do not introduce new dependencies or restructure working code unless density. Do not introduce new dependencies or restructure working code unless
the linked issue or stated task demands it. the issue demands it.
9. **Disclose AI automation.** When an agent creates or co-creates a PR or Issue, 9. **Disclose AI automation.** When an agent produces or co-produces a change,
append a final body line with the model and effort used (e.g. append a footer line to the PR body with the model and effort used (e.g.
`Assisted by: GPT-5.6 High`). Effort may be omitted when the runtime does not `Assisted by: GPT-5.6 High`). The PR template intentionally omits this line —
report it. Human contributors are not asked to disclose AI assistance. the agent adds it itself, humans are not asked to declare anything. Effort may
Disclosure is transparency only and carries no positive or negative weight. be omitted when the runtime does not report it. Disclosure is transparency
only; it does not substitute for any rule above.
10. **Compiled workflows.** The AI-slop review policy in 10. **Compiled workflows.** The AI-slop review policy in
[pr-ai-slop-review.md](.github/workflows/pr-ai-slop-review.md) is compiled: [pr-ai-slop-review.md](.github/workflows/pr-ai-slop-review.md) is compiled:
after editing it, run `gh aw compile` and commit the regenerated after editing it, run `gh aw compile` and commit the regenerated
@@ -61,10 +59,8 @@ the ownership evidence the AI-slop review evaluates (see
[`template/Changelog.md`](template/Changelog.md): one line per [`template/Changelog.md`](template/Changelog.md): one line per
user-visible change, no internals. user-visible change, no internals.
## Issue and PR Workflows ## Pull Request Shape
When drafting, triaging, splitting, or implementing from a community issue, use Describe three things, briefly: the problem (with issue link), why this approach
the conditional checklist in [`.github/ISSUE_GUIDELINES.md`](.github/ISSUE_GUIDELINES.md). solves it, and what changed. See
Do not make GitHub issue changes without explicit authorization. For PRs, follow [`PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
the directly loaded [PR template](.github/PULL_REQUEST_TEMPLATE.md); do not
duplicate its prompts here.
+6 -19
View File
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
### Prerequisites ### Prerequisites
1. **Install Rust and Node.js** 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 ### Windows Users
@@ -51,21 +51,18 @@ rustup set default-host x86_64-pc-windows-msvc
### Install Node.js Package Manager ### 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 ```bash
npm install --global corepack
corepack enable corepack enable
``` ```
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
### Install Project Dependencies ### Install Project Dependencies
Node.js dependencies: Node.js dependencies:
```bash ```bash
pnpm install --frozen-lockfile pnpm install
``` ```
Ubuntu-only system packages: Ubuntu-only system packages:
@@ -81,24 +78,14 @@ pnpm run prebuild
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries 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 the Development Server
Run these commands from the root of `clash-verge-rev`:
```bash ```bash
pnpm dev # Build the development service and start the desktop app pnpm dev # Standard
pnpm dev:diff # Alias of pnpm dev pnpm dev:diff # If an app instance already exists
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature 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 ### Build the Project
Standard build: Standard build:
Generated
+228 -110
View File
@@ -33,7 +33,6 @@ dependencies = [
"cipher", "cipher",
"cpubits", "cpubits",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
"zeroize",
] ]
[[package]] [[package]]
@@ -136,6 +135,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "arboard" name = "arboard"
version = "3.6.1" version = "3.6.1"
@@ -1081,7 +1089,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "clash-verge" name = "clash-verge"
version = "2.5.10" version = "2.5.9"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -1111,7 +1119,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"libc", "libc",
"log", "log",
"minisign-verify", "minisign-verify 0.3.0",
"nanoid", "nanoid",
"network-interface", "network-interface",
"objc2", "objc2",
@@ -1167,7 +1175,7 @@ dependencies = [
"windows-service", "windows-service",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.56.0", "winreg 0.56.0",
"zip", "zip 9.0.4",
] ]
[[package]] [[package]]
@@ -1334,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1862,6 +1870,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -2015,7 +2034,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2274,7 +2293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2790,7 +2809,7 @@ dependencies = [
"gobject-sys 0.21.5", "gobject-sys 0.21.5",
"libc", "libc",
"system-deps 7.0.8", "system-deps 7.0.8",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3344,7 +3363,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.61.2", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -4032,9 +4051,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.23.0" version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
dependencies = [ dependencies = [
"sha2 0.11.1", "sha2 0.11.1",
] ]
@@ -4151,6 +4170,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "minisign-verify" name = "minisign-verify"
version = "0.3.0" version = "0.3.0"
@@ -4215,7 +4240,7 @@ dependencies = [
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4382,7 +4407,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4859,7 +4884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5524,7 +5549,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5974,13 +5999,12 @@ dependencies = [
[[package]] [[package]]
name = "rust-ini" name = "rust-ini"
version = "0.21.1" version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"ordered-multimap", "ordered-multimap",
"trim-in-place",
] ]
[[package]] [[package]]
@@ -6028,7 +6052,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6041,7 +6065,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6100,7 +6124,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6601,6 +6625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [ dependencies = [
"libc", "libc",
"sigchld",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -6620,6 +6645,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook 0.3.18",
]
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.1.17" version = "0.1.17"
@@ -6630,6 +6666,16 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
] ]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -6720,7 +6766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6835,12 +6881,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "static_vcruntime"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.9.0" version = "0.9.0"
@@ -6900,9 +6940,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.9" version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"serde", "serde",
@@ -7069,9 +7109,9 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.37.2" version = "0.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
@@ -7149,9 +7189,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.12.4" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -7197,16 +7237,16 @@ dependencies = [
"tray-icon", "tray-icon",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com 0.39.1",
"window-vibrancy", "window-vibrancy",
"windows 0.62.2", "windows 0.62.2",
] ]
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.7.3" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -7218,8 +7258,6 @@ dependencies = [
"semver 1.0.28", "semver 1.0.28",
"serde", "serde",
"serde_json", "serde_json",
"static_vcruntime",
"strsim",
"tauri-utils", "tauri-utils",
"tauri-winres", "tauri-winres",
"walkdir", "walkdir",
@@ -7227,11 +7265,11 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.7.3" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.22.1",
"brotli", "brotli",
"ico", "ico",
"json-patch", "json-patch",
@@ -7254,9 +7292,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.7.3" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -7312,9 +7350,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-clipboard-manager" name = "tauri-plugin-clipboard-manager"
version = "2.5.0" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81" checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a"
dependencies = [ dependencies = [
"arboard", "arboard",
"log", "log",
@@ -7327,9 +7365,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.6.2" version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4" checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736"
dependencies = [ dependencies = [
"dunce", "dunce",
"plist", "plist",
@@ -7375,9 +7413,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.8.3" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@@ -7393,9 +7431,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.7.1" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b" checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -7417,9 +7455,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-global-shortcut" name = "tauri-plugin-global-shortcut"
version = "2.4.1" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d" checksum = "0bd2e1f725891a1613af8f25bf918ba3357654b0fd3f2d794c0ce76cbdd92ed2"
dependencies = [ dependencies = [
"global-hotkey", "global-hotkey",
"log", "log",
@@ -7432,9 +7470,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-http" name = "tauri-plugin-http"
version = "2.8.3" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c57e3451553f95a1db3bfed6a358a77ce5080b6399a242c6eadac113f97abd2" checksum = "93e9d6b3319e101bc1be27cfa0fb4054d7784a12509c67ead0033b82180e5ed1"
dependencies = [ dependencies = [
"bytes", "bytes",
"cookie_store", "cookie_store",
@@ -7451,7 +7489,7 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"url", "url",
"urlpattern", "urlpattern 0.3.0",
] ]
[[package]] [[package]]
@@ -7481,9 +7519,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.7.0" version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b" checksum = "533770889abb60a0407d5a32096182f0e6463f5a3dfaa675be94580aa14070e1"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
@@ -7496,14 +7534,13 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"time", "time",
"url", "url",
"zbus",
] ]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.7.2" version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345"
dependencies = [ dependencies = [
"dunce", "dunce",
"glob", "glob",
@@ -7533,12 +7570,11 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.5.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" checksum = "094bdbc47e5efe36544ba21b3a53ab5c724150236cf55e6c9be46191deb6eab6"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"libc",
"log", "log",
"open", "open",
"os_pipe", "os_pipe",
@@ -7551,24 +7587,22 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"windows-sys 0.60.2",
] ]
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.14.1" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b" checksum = "3cb0b2ea3e85ca287990d3859a29cc5cb18024240fd78f62e027fb7963670f18"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.22.1",
"dirs 7.0.0", "dirs 7.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
"infer", "infer",
"libc",
"log", "log",
"minisign-verify", "minisign-verify 0.2.5",
"osakit", "osakit",
"percent-encoding", "percent-encoding",
"reqwest 0.13.5", "reqwest 0.13.5",
@@ -7585,14 +7619,14 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"zip", "zip 4.6.1",
] ]
[[package]] [[package]]
name = "tauri-plugin-wdio-webdriver" name = "tauri-plugin-wdio-webdriver"
version = "1.5.0" version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f76676d510d271d80abcf8b0552987b5b4f0eae5e3f906e9b5660d43c97c62e" checksum = "52c5e97428174a52b7f4357bbc4535fcce52c6a06c8ee7a379502f7cda1b6045"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -7616,9 +7650,9 @@ dependencies = [
"tracing", "tracing",
"uuid", "uuid",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com 0.38.2",
"windows 0.62.2", "windows 0.61.3",
"windows-core 0.62.2", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -7638,9 +7672,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.2" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@@ -7657,15 +7691,15 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com 0.39.1",
"windows 0.62.2", "windows 0.62.2",
] ]
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.12.2" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -7683,7 +7717,7 @@ dependencies = [
"tracing", "tracing",
"url", "url",
"webkit2gtk", "webkit2gtk",
"webview2-com", "webview2-com 0.39.1",
"windows 0.62.2", "windows 0.62.2",
"wry", "wry",
] ]
@@ -7721,9 +7755,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.10.2" version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@@ -7752,7 +7786,7 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
"toml 1.1.6+spec-1.1.0", "toml 1.1.6+spec-1.1.0",
"url", "url",
"urlpattern", "urlpattern 0.6.0",
"uuid", "uuid",
"walkdir", "walkdir",
] ]
@@ -7786,10 +7820,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -7871,7 +7905,7 @@ dependencies = [
"regex", "regex",
"semver 0.11.0", "semver 0.11.0",
"sha2 0.9.9", "sha2 0.9.9",
"signal-hook", "signal-hook 0.1.17",
"terminfo", "terminfo",
"termios", "termios",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -8455,7 +8489,7 @@ dependencies = [
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8469,12 +8503,6 @@ dependencies = [
"petgraph", "petgraph",
] ]
[[package]]
name = "trim-in-place"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daa60874b45b168f67f3f9ba1ee821ad77144d6fa19cef1cd1874a7450a948da"
[[package]] [[package]]
name = "triomphe" name = "triomphe"
version = "0.1.16" version = "0.1.16"
@@ -8534,9 +8562,9 @@ dependencies = [
[[package]] [[package]]
name = "typed-path" name = "typed-path"
version = "0.13.0" version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typeid" name = "typeid"
@@ -8564,7 +8592,48 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.60.2", "windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-ucd-ident"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
] ]
[[package]] [[package]]
@@ -8626,6 +8695,18 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlpattern"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
dependencies = [
"regex",
"serde",
"unic-ucd-ident",
"url",
]
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.6.0" version = "0.6.0"
@@ -9017,6 +9098,20 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webview2-com"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.39.1" version = "0.39.1"
@@ -9024,7 +9119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
dependencies = [ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys 0.39.1",
"windows 0.62.2", "windows 0.62.2",
"windows-core 0.62.2", "windows-core 0.62.2",
] ]
@@ -9040,6 +9135,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "webview2-com-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]] [[package]]
name = "webview2-com-sys" name = "webview2-com-sys"
version = "0.39.1" version = "0.39.1"
@@ -9106,7 +9212,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -9127,7 +9233,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"windows-sys 0.60.2", "windows-sys 0.61.2",
"windows-version", "windows-version",
] ]
@@ -9702,7 +9808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -9743,11 +9849,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.57.1" version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.22.1",
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
@@ -9768,6 +9874,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
"once_cell",
"percent-encoding", "percent-encoding",
"raw-window-handle", "raw-window-handle",
"sha2 0.10.9", "sha2 0.10.9",
@@ -9778,7 +9885,7 @@ dependencies = [
"url", "url",
"webkit2gtk", "webkit2gtk",
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com 0.39.1",
"windows 0.62.2", "windows 0.62.2",
"windows-core 0.62.2", "windows-core 0.62.2",
"windows-version", "windows-version",
@@ -10034,9 +10141,21 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "9.1.3" version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"aes", "aes",
"bzip2", "bzip2",
@@ -10055,7 +10174,6 @@ dependencies = [
"sha1 0.11.0", "sha1 0.11.0",
"time", "time",
"typed-path", "typed-path",
"windows-sys 0.61.2",
"zeroize", "zeroize",
"zopfli", "zopfli",
"zstd", "zstd",
+2 -2
View File
@@ -61,8 +61,8 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" } tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44" tracing = "0.1.44"
tauri = { version = "2.12.4" } tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.5.0" tauri-plugin-clipboard-manager = "2.4.1"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
+14 -24
View File
@@ -1,31 +1,16 @@
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land; ## v2.5.9
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.5.10
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复恢复备份的同时修改配置,可能导致配置丢失或错乱的问题 - 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复切换规则模式与配置变更并发时可能丢失或错乱的问题 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details> </details>
@@ -38,5 +23,10 @@
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details> </details>
@@ -11,7 +11,6 @@ mod chatgpt;
mod claude; mod claude;
mod disney_plus; mod disney_plus;
mod gemini; mod gemini;
mod muse;
mod netflix; mod netflix;
mod prime_video; mod prime_video;
mod spotify; mod spotify;
@@ -31,7 +30,6 @@ enum UnlockCheck {
ChatGptWeb, ChatGptWeb,
Claude, Claude,
Gemini, Gemini,
MuseAi,
YouTubePremium, YouTubePremium,
BahamutAnime, BahamutAnime,
Netflix, Netflix,
@@ -48,7 +46,6 @@ impl UnlockCheck {
Self::ChatGptWeb, Self::ChatGptWeb,
Self::Claude, Self::Claude,
Self::Gemini, Self::Gemini,
Self::MuseAi,
Self::YouTubePremium, Self::YouTubePremium,
Self::BahamutAnime, Self::BahamutAnime,
Self::Netflix, Self::Netflix,
@@ -69,7 +66,6 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME, Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
Self::Claude => claude::CLAUDE_NAME, Self::Claude => claude::CLAUDE_NAME,
Self::Gemini => gemini::GEMINI_NAME, Self::Gemini => gemini::GEMINI_NAME,
Self::MuseAi => muse::MUSE_AI_NAME,
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME, Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME, Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
Self::Netflix => netflix::NETFLIX_NAME, Self::Netflix => netflix::NETFLIX_NAME,
@@ -87,7 +83,6 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await, Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
Self::Claude => claude::check_claude(client).await, Self::Claude => claude::check_claude(client).await,
Self::Gemini => gemini::check_gemini(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::YouTubePremium => youtube::check_youtube_premium(client).await,
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await, Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
Self::Netflix => netflix::check_netflix(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)
}
-48
View File
@@ -1,51 +1,3 @@
## 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 ## v2.5.8
<details> <details>
-8
View File
@@ -129,7 +129,6 @@ export default defineConfig([
'eslint.config.ts', 'eslint.config.ts',
`vite.config.mts`, `vite.config.mts`,
'scripts/*.mjs', 'scripts/*.mjs',
'scripts-workflow/*.mjs',
'tests/*.ts', 'tests/*.ts',
'src/polyfills/*.js', 'src/polyfills/*.js',
'tests/*.mjs', '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}'], files: ['scripts/perf/*.{mjs,mts}'],
languageOptions: { languageOptions: {
+20 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.5.10", "version": "2.5.9",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"prepare": "husky || true", "prepare": "husky || true",
@@ -14,7 +14,7 @@
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release", "build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri", "tauri": "tauri",
"web:dev": "vite", "web:dev": "vite",
"web:build": "tsrs -p . --noEmit && vite build", "web:build": "tsc --noEmit && vite build",
"web:serve": "vite preview", "web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs", "prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs", "updater": "node scripts/updater.mjs",
@@ -32,7 +32,7 @@
"i18n:types": "node scripts/generate-i18n-keys.mjs", "i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs", "gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke", "arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsrs -p . --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"knip:check": "knip", "knip:check": "knip",
@@ -54,13 +54,13 @@
"@mui/icons-material": "^9.4.0", "@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0", "@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13", "@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.4", "@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "2.5.0", "@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.3", "@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.7.1", "@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.3", "@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.2", "@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.14.1", "@tauri-apps/plugin-updater": "2.13.1",
"ahooks": "^3.10.0", "ahooks": "^3.10.0",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
@@ -79,11 +79,12 @@
"react-dom": "19.3.1", "react-dom": "19.3.1",
"react-error-boundary": "6.1.6", "react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-i18next": "17.1.9", "react-i18next": "17.0.19",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1", "swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
@@ -94,15 +95,15 @@
"@biomejs/biome": "^2.5.14", "@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5", "@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3", "@tauri-apps/cli": "2.12.1",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^26.0.0", "@types/node": "^24.13.6",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.5.0", "@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.1", "adm-zip": "^0.6.1",
"axios": "^1.20.0", "axios": "^1.20.0",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
@@ -117,24 +118,23 @@
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0", "eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"glob": "^14.0.0", "glob": "^13.0.6",
"globals": "^17.12.0", "globals": "^17.12.0",
"https-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"knip": "6.44.1", "knip": "6.41.0",
"lint-staged": "^17.5.1", "lint-staged": "^17.5.1",
"sass": "^1.104.1", "sass": "^1.104.1",
"tar": "^7.5.22", "tar": "^7.5.22",
"terser": "^5.51.2", "terser": "^5.51.2",
"tsrs": "0.20.0-ts7.1.0-beta",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.70.0", "typescript-eslint": "^8.70.0",
"vite": "^8.3.0", "vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"webdriverio": "10.1.0", "webdriverio": "10.0.2",
"ws": "8.22.1" "ws": "8.22.0"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,mjs}": [ "*.{ts,tsx,js,mjs}": [
@@ -146,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.16.1" "packageManager": "pnpm@12.14.0"
} }
+852 -565
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -10,11 +10,3 @@ allowBuilds:
unrs-resolver: true unrs-resolver: true
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- dayjs@1.11.21 - 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"], "matchPackageNames": ["typescript"],
"allowedVersions": "<7.0.0 || >=7.1.0" "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", "description": "Prevent project from supply chain attack",
"matchDatasources": ["npm"], "matchDatasources": ["npm"],
+1 -4
View File
@@ -41,10 +41,7 @@ fi
tmpfile=$(mktemp) tmpfile=$(mktemp)
{ {
node -e ' cat "$CHANGELOG"
const fs = require("node:fs");
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
' "$CHANGELOG"
echo echo
echo "" echo ""
if [ -f "$HISTORY" ]; then if [ -f "$HISTORY" ]; then
-801
View File
@@ -1,801 +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)}
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-lock.yaml",
"pnpm-workspace.yaml", "pnpm-workspace.yaml",
"scripts/contract-inputs.json", "scripts/contract-inputs.json",
".github/workflows/contract-parity.yml", ".github/workflows/frontend-check.yml",
".husky/pre-push" ".husky/pre-push"
] ]
} }
+4 -5
View File
@@ -434,13 +434,12 @@ for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
devicePixelRatio, devicePixelRatio,
]) ])
run.viewport = { dom: viewport, driver: await browser.getWindowRect() } run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
const driverScale = run.viewport.driver.width / 1000
assert( assert(
viewport[0] === 1000 && run.viewport.driver.width / viewport[2] === 1000 &&
run.viewport.driver.height / viewport[2] === 700 &&
viewport[0] === 1000 &&
viewport[1] > 0 && viewport[1] > 0 &&
viewport[1] <= 700 && viewport[1] <= 700,
(driverScale === 1 || driverScale === viewport[2]) &&
run.viewport.driver.height / driverScale === 700,
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`, `unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
) )
assert( assert(
+7 -6
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
} }
if (!META_MAP[`${platform}-${arch}`]) { 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}`]) { 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 name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32' const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz' const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function mihomoAlpha() {
} }
} }
function mihomo() { function clashMeta() {
const name = META_MAP[`${platform}-${arch}`] const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32' const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz' const urlExt = isWin ? 'zip' : 'gz'
@@ -733,12 +733,13 @@ const tasks = [
{ {
name: 'verge-mihomo-alpha', name: 'verge-mihomo-alpha',
func: () => func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())), getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
retry: 5, retry: 5,
}, },
{ {
name: 'verge-mihomo', name: 'verge-mihomo',
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())), func: () =>
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
retry: 5, retry: 5,
}, },
// After both sidecar tasks: it hashes what they downloaded. // After both sidecar tasks: it hashes what they downloaded.
+13 -13
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.5.10" version = "2.5.9"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.7.3", features = [] } tauri-build = { version = "2.7.1", features = [] }
[dependencies] [dependencies]
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
@@ -72,14 +72,14 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard", "guard",
] } ] }
network-interface = { version = "2.0.5", features = ["serde"] } network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.5.0" tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.2" tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.3" tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.7.1" tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0" tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.2" tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "2.5.0"
zip = "9.1.3" zip = "9.0.4"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
@@ -90,10 +90,10 @@ getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
gethostname = "1.1.0" gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.7.0" tauri-plugin-notification = "2.5.1"
tokio-stream = "0.1.19" tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] } backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.3" tauri-plugin-http = "2.8.0"
tracing = { workspace = true } tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = 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" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
@@ -120,7 +120,7 @@ libc = "0.2.191"
clash-verge-signal = { workspace = true } clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true } tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5" objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [ objc2-foundation = { version = "0.3.2", features = [
"NSString", "NSString",
@@ -153,8 +153,8 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0" tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.1" tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.14.1" tauri-plugin-updater = "2.13.1"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } criterion = { workspace = true }
+5 -10
View File
@@ -1,9 +1,10 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
{ {
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses or examples. // Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
// Give non-app targets their own manifest so tests and examples can resolve TaskDialogIndirect. // Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?; let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml"); let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
std::fs::write( std::fs::write(
@@ -24,14 +25,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
</assembly> </assembly>
"#, "#,
)?; )?;
let manifest_display = manifest_path.display(); println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
if std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() { println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{manifest_display}");
} else {
println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-examples=/MANIFESTINPUT:{manifest_display}");
}
} }
#[cfg(feature = "clippy")] #[cfg(feature = "clippy")]
+56 -13
View File
@@ -184,41 +184,84 @@ impl IClashTemp {
_ => None, _ => 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 let mut port = config
.get(key) .get("redir-port")
.and_then(|value| match value { .and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(), Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None, _ => None,
}) })
.unwrap_or(default); .unwrap_or(7895);
if port == 0 { if port == 0 {
port = default; port = 7895;
} }
port 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")] #[cfg(target_os = "linux")]
fn guard_tproxy_port(config: &Mapping) -> u16 { 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 { 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 { 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 { 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 { pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
-9
View File
@@ -14,15 +14,6 @@ mod verge;
pub(crate) use self::config::Config; pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*}; 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) { 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;"; return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
} }
+108 -71
View File
@@ -1,5 +1,3 @@
use super::patch_opt_field;
use crate::{ use crate::{
config::profiles, config::profiles,
utils::{ utils::{
@@ -133,18 +131,19 @@ impl PrfOption {
match (one, other) { match (one, other) {
(Some(a_ref), Some(b_ref)) => { (Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone(); let mut result = a_ref.clone();
patch_opt_field!(result, b_ref, user_agent); result.user_agent = b_ref.user_agent.clone().or(result.user_agent);
patch_opt_field!(result, b_ref, with_proxy); result.with_proxy = b_ref.with_proxy.or(result.with_proxy);
patch_opt_field!(result, b_ref, self_proxy); result.self_proxy = b_ref.self_proxy.or(result.self_proxy);
patch_opt_field!(result, b_ref, danger_accept_invalid_certs); result.danger_accept_invalid_certs =
patch_opt_field!(result, b_ref, allow_auto_update); b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs);
patch_opt_field!(result, b_ref, update_interval); result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update);
patch_opt_field!(result, b_ref, merge); result.update_interval = b_ref.update_interval.or(result.update_interval);
patch_opt_field!(result, b_ref, script); result.merge = b_ref.merge.clone().or(result.merge);
patch_opt_field!(result, b_ref, rules); result.script = b_ref.script.clone().or(result.script);
patch_opt_field!(result, b_ref, proxies); result.rules = b_ref.rules.clone().or(result.rules);
patch_opt_field!(result, b_ref, groups); result.proxies = b_ref.proxies.clone().or(result.proxies);
patch_opt_field!(result, b_ref, timeout_seconds); result.groups = b_ref.groups.clone().or(result.groups);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
Some(result) Some(result)
} }
(Some(a_ref), None) => Some(a_ref.clone()), (Some(a_ref), None) => Some(a_ref.clone()),
@@ -182,38 +181,6 @@ 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( async fn from_local(
name: String, name: String,
desc: String, desc: String,
@@ -230,7 +197,31 @@ impl PrfItem {
let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.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 { Ok(Self {
uid: Some(uid), uid: Some(uid),
itype: Some("local".into()), itype: Some("local".into()),
@@ -384,7 +375,31 @@ impl PrfItem {
bail!("profile does not contain `proxies` or `proxy-providers`"); 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 { Ok(Self {
uid: Some(uid), uid: Some(uid),
@@ -446,30 +461,46 @@ impl PrfItem {
} }
} }
fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self { fn from_rules() -> Self {
let uid = help::get_uid(prefix).into(); let uid = help::get_uid("r").into();
let file = format!("{uid}.yaml").into(); // yaml ext let file = format!("{uid}.yaml").into(); // yaml ext
Self { Self {
uid: Some(uid), uid: Some(uid),
itype: Some(itype.into()), itype: Some("rules".into()),
file: Some(file), file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize), updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(template.into()), file_data: Some(tmpl::ITEM_RULES.into()),
..Default::default() ..Default::default()
} }
} }
fn from_rules() -> Self {
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
}
fn from_proxies() -> Self { fn from_proxies() -> Self {
Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES) let uid = help::get_uid("p").into();
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 { fn from_groups() -> Self {
Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS) let uid = help::get_uid("g").into();
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> { pub(crate) async fn read_file(&self) -> Result<String> {
@@ -485,20 +516,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 { impl PrfItem {
current_field!(current_merge, merge); pub(crate) fn current_merge(&self) -> Option<&String> {
current_field!(current_script, script); self.option.as_ref().and_then(|o| o.merge.as_ref())
current_field!(current_rules, rules); }
current_field!(current_proxies, proxies);
current_field!(current_groups, groups); 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 { fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
+62 -40
View File
@@ -1,5 +1,5 @@
use super::{ use super::{
PrfOption, patch_opt_field, PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url}, prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
}; };
use crate::core::notify::{Refresh, announce}; use crate::core::notify::{Refresh, announce};
@@ -86,6 +86,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 { impl IProfiles {
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> { 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)?; let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
@@ -236,15 +244,15 @@ impl IProfiles {
for each in items.iter_mut() { for each in items.iter_mut() {
if each.uid.as_deref() == Some(uid) { if each.uid.as_deref() == Some(uid) {
patch_opt_field!(each, item, itype); patch!(each, item, itype);
patch_opt_field!(each, item, name); patch!(each, item, name);
patch_opt_field!(each, item, desc); patch!(each, item, desc);
patch_opt_field!(each, item, file); patch!(each, item, file);
patch_opt_field!(each, item, url); patch!(each, item, url);
patch_opt_field!(each, item, selected); patch!(each, item, selected);
patch_opt_field!(each, item, extra); patch!(each, item, extra);
patch_opt_field!(each, item, updated); patch!(each, item, updated);
patch_opt_field!(each, item, option); patch!(each, item, option);
self.items = Some(items); self.items = Some(items);
return self.save_file().await; return self.save_file().await;
@@ -412,21 +420,6 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
profiles_append_item_safe(item).await 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<()> { pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await profiles_append_item_to_safe(&profiles, item).await
@@ -442,19 +435,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<()> { 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<()> { 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<()> { 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<()> { 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)] #[derive(Debug, PartialEq, Eq)]
@@ -696,21 +713,19 @@ where
Ok(()) 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. /// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name); let group_name = String::from(group_name);
let node = String::from(node); let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move { 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)); return Ok((profiles, false));
}; };
@@ -745,7 +760,14 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> { pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move { 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)); return Ok((profiles, false));
}; };
+84 -76
View File
@@ -1,6 +1,6 @@
use crate::config::Config; use crate::config::Config;
use crate::{ use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted}, config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted},
constants::network, constants::network,
utils::{dirs, help}, utils::{dirs, help},
}; };
@@ -39,7 +39,7 @@ pub struct IVerge {
pub traffic_graph: Option<bool>, pub traffic_graph: Option<bool>,
/// show memory info (only for Mihomo) /// show memory info (only for Clash Meta)
pub enable_memory_usage: Option<bool>, pub enable_memory_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -432,90 +432,98 @@ impl IVerge {
#[allow(clippy::cognitive_complexity)] #[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) { pub fn patch_config(&mut self, patch: &Self) {
patch_opt_field!(self, patch, app_log_level); macro_rules! patch {
patch_opt_field!(self, patch, app_log_max_size); ($key: tt) => {
patch_opt_field!(self, patch, app_log_max_count); if patch.$key.is_some() {
self.$key = patch.$key.clone();
}
};
}
patch_opt_field!(self, patch, language); patch!(app_log_level);
patch_opt_field!(self, patch, theme_mode); patch!(app_log_max_size);
patch_opt_field!(self, patch, tray_event); patch!(app_log_max_count);
patch_opt_field!(self, patch, env_type);
patch_opt_field!(self, patch, start_page); patch!(language);
patch_opt_field!(self, patch, startup_script); patch!(theme_mode);
patch_opt_field!(self, patch, traffic_graph); patch!(tray_event);
patch_opt_field!(self, patch, enable_memory_usage); patch!(env_type);
patch_opt_field!(self, patch, enable_group_icon); patch!(start_page);
patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur); 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")] #[cfg(target_os = "macos")]
patch_opt_field!(self, patch, tray_icon); patch!(tray_icon);
patch_opt_field!(self, patch, menu_icon); patch!(menu_icon);
patch_opt_field!(self, patch, menu_order); patch!(menu_order);
patch_opt_field!(self, patch, proxy_group_tools_position); patch!(proxy_group_tools_position);
patch_opt_field!(self, patch, notice_position); patch!(notice_position);
patch_opt_field!(self, patch, collapse_navbar); patch!(collapse_navbar);
patch_opt_field!(self, patch, common_tray_icon); patch!(common_tray_icon);
patch_opt_field!(self, patch, sysproxy_tray_icon); patch!(sysproxy_tray_icon);
patch_opt_field!(self, patch, tun_tray_icon); patch!(tun_tray_icon);
patch_opt_field!(self, patch, enable_tun_mode); patch!(enable_tun_mode);
patch_opt_field!(self, patch, enable_auto_launch); patch!(enable_auto_launch);
patch_opt_field!(self, patch, enable_silent_start); patch!(enable_silent_start);
patch_opt_field!(self, patch, enable_hover_jump_navigator); patch!(enable_hover_jump_navigator);
patch_opt_field!(self, patch, hover_jump_navigator_delay); patch!(hover_jump_navigator_delay);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_port); patch!(verge_redir_port);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
patch_opt_field!(self, patch, verge_redir_enabled); patch!(verge_redir_enabled);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_port); patch!(verge_tproxy_port);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
patch_opt_field!(self, patch, verge_tproxy_enabled); patch!(verge_tproxy_enabled);
patch_opt_field!(self, patch, verge_mixed_port); patch!(verge_mixed_port);
patch_opt_field!(self, patch, verge_socks_port); patch!(verge_socks_port);
patch_opt_field!(self, patch, verge_socks_enabled); patch!(verge_socks_enabled);
patch_opt_field!(self, patch, verge_port); patch!(verge_port);
patch_opt_field!(self, patch, verge_http_enabled); patch!(verge_http_enabled);
patch_opt_field!(self, patch, enable_system_proxy); patch!(enable_system_proxy);
patch_opt_field!(self, patch, enable_proxy_guard); patch!(enable_proxy_guard);
patch_opt_field!(self, patch, enable_bypass_check); patch!(enable_bypass_check);
patch_opt_field!(self, patch, use_default_bypass); patch!(use_default_bypass);
patch_opt_field!(self, patch, system_proxy_bypass); patch!(system_proxy_bypass);
patch_opt_field!(self, patch, proxy_guard_duration); patch!(proxy_guard_duration);
patch_opt_field!(self, patch, proxy_auto_config); patch!(proxy_auto_config);
patch_opt_field!(self, patch, pac_file_content); patch!(pac_file_content);
patch_opt_field!(self, patch, proxy_host); patch!(proxy_host);
patch_opt_field!(self, patch, theme_setting); patch!(theme_setting);
patch_opt_field!(self, patch, web_ui_list); patch!(web_ui_list);
patch_opt_field!(self, patch, clash_core); patch!(clash_core);
patch_opt_field!(self, patch, hotkeys); patch!(hotkeys);
patch_opt_field!(self, patch, enable_global_hotkey); patch!(enable_global_hotkey);
patch_opt_field!(self, patch, auto_close_connection); patch!(auto_close_connection);
patch_opt_field!(self, patch, auto_check_update); patch!(auto_check_update);
patch_opt_field!(self, patch, default_latency_test); patch!(default_latency_test);
patch_opt_field!(self, patch, default_latency_timeout); patch!(default_latency_timeout);
patch_opt_field!(self, patch, enable_auto_delay_detection); patch!(enable_auto_delay_detection);
patch_opt_field!(self, patch, auto_delay_detection_interval_minutes); patch!(auto_delay_detection_interval_minutes);
patch_opt_field!(self, patch, enable_builtin_enhanced); patch!(enable_builtin_enhanced);
patch_opt_field!(self, patch, proxy_layout_column); patch!(proxy_layout_column);
patch_opt_field!(self, patch, test_list); patch!(test_list);
patch_opt_field!(self, patch, auto_log_clean); patch!(auto_log_clean);
patch_opt_field!(self, patch, enable_auto_backup_schedule); patch!(enable_auto_backup_schedule);
patch_opt_field!(self, patch, auto_backup_interval_hours); patch!(auto_backup_interval_hours);
patch_opt_field!(self, patch, auto_backup_on_change); patch!(auto_backup_on_change);
patch_opt_field!(self, patch, webdav_url); patch!(webdav_url);
patch_opt_field!(self, patch, webdav_username); patch!(webdav_username);
patch_opt_field!(self, patch, webdav_password); patch!(webdav_password);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
patch_opt_field!(self, patch, enable_tray_speed); patch!(enable_tray_speed);
patch_opt_field!(self, patch, tray_proxy_groups_display_mode); patch!(tray_proxy_groups_display_mode);
patch_opt_field!(self, patch, tray_inline_outbound_modes); patch!(tray_inline_outbound_modes);
patch_opt_field!(self, patch, enable_auto_light_weight_mode); patch!(enable_auto_light_weight_mode);
patch_opt_field!(self, patch, auto_light_weight_minutes); patch!(auto_light_weight_minutes);
patch_opt_field!(self, patch, enable_dns_settings); patch!(enable_dns_settings);
patch_opt_field!(self, patch, home_cards); patch!(home_cards);
patch_opt_field!(self, patch, enable_external_controller); patch!(enable_external_controller);
} }
pub fn get_log_level(&self) -> LevelFilter { pub fn get_log_level(&self) -> LevelFilter {
-8
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 { pub mod timing {
use super::Duration; use super::Duration;
@@ -82,7 +75,6 @@ pub mod tun {
"auto-redirect", "auto-redirect",
"auto-detect-interface", "auto-detect-interface",
"dns-hijack", "dns-hijack",
"dns-mode",
"strict-route", "strict-route",
"mtu", "mtu",
]; ];
+15 -9
View File
@@ -7,10 +7,11 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use chrono::Local; use chrono::Local;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use std::{ use std::{
path::PathBuf, path::PathBuf,
sync::{ sync::{
OnceLock, Arc, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering}, atomic::{AtomicBool, AtomicI64, Ordering},
}, },
time::{Duration, UNIX_EPOCH}, time::{Duration, UNIX_EPOCH},
@@ -81,9 +82,8 @@ impl Default for AutoBackupSettings {
} }
pub struct AutoBackupManager { pub struct AutoBackupManager {
settings: Arc<RwLock<AutoBackupSettings>>,
settings_tx: watch::Sender<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, runner_started: AtomicBool,
exec_lock: Mutex<()>, exec_lock: Mutex<()>,
last_backup: AtomicI64, last_backup: AtomicI64,
@@ -93,10 +93,10 @@ impl AutoBackupManager {
pub fn global() -> &'static Self { pub fn global() -> &'static Self {
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new(); static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| { INSTANCE.get_or_init(|| {
let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default()); let (tx, _rx) = watch::channel(AutoBackupSettings::default());
Self { Self {
settings_tx, settings: Arc::new(RwLock::new(AutoBackupSettings::default())),
settings_rx, settings_tx: tx,
runner_started: AtomicBool::new(false), runner_started: AtomicBool::new(false),
exec_lock: Mutex::new(()), exec_lock: Mutex::new(()),
last_backup: AtomicI64::new(0), last_backup: AtomicI64::new(0),
@@ -106,14 +106,20 @@ impl AutoBackupManager {
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
let settings = Self::load_settings().await; 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); self.maybe_start_runner(settings);
Ok(()) Ok(())
} }
pub async fn refresh_settings(&self) -> Result<()> { pub async fn refresh_settings(&self) -> Result<()> {
let settings = Self::load_settings().await; 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); self.maybe_start_runner(settings);
Ok(()) Ok(())
} }
@@ -187,7 +193,7 @@ impl AutoBackupManager {
} }
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> { 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 { if trigger.is_schedule() && !snapshot.schedule_enabled {
return Ok(()); return Ok(());
+9
View File
@@ -466,3 +466,12 @@ impl Hotkey {
(del_list, add_list) (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); static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
struct WindowListener { static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
handler_id: AtomicU32, static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0);
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 CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None); 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() { fn setup_window_close_listener() {
WINDOW_CLOSE_LISTENER.setup(|| { if let Some(window) = WindowManager::get_main_window() {
std::mem::drop(AsyncHandler::spawn(|| async { let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel);
setup_light_weight_timer().await; if previous_handler_id != 0 {
})); window.unlisten(previous_handler_id);
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时"); 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() { 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() { fn setup_webview_focus_listener() {
WEBVIEW_FOCUS_LISTENER.setup(|| { if let Some(window) = WindowManager::get_main_window() {
cancel_light_weight_timer(); let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel);
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时"); 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() { 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() { async fn setup_light_weight_timer() {
+10 -2
View File
@@ -6,7 +6,7 @@ use crate::{
notification::{self, FailedOperation}, notification::{self, FailedOperation},
runstate::RUN_STATE, runstate::RUN_STATE,
service, service,
sysopt::{Sysopt, format_bypass}, sysopt::Sysopt,
}, },
process::AsyncHandler, process::AsyncHandler,
utils::{ utils::{
@@ -204,6 +204,8 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
} }
const LOOPBACK_HOST: &str = "127.0.0.1"; 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; const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new(); static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
@@ -325,7 +327,13 @@ fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or(""); let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL"); 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.to_owned()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}")
} else {
custom.to_owned()
};
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN); truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass) Ok(bypass)
} }
+40 -43
View File
@@ -593,71 +593,50 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
} }
#[cfg(target_os = "windows")] #[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 deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand; use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _; use std::os::windows::process::CommandExt as _;
let token = Token::with_current_process()?; let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
let level = token.privilege_level()?; Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
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)
}
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() { if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}")); 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() { if !status.success() {
bail!( bail!(
"failed to uninstall service with status {}", "failed to uninstall service with status {}",
status.code().unwrap_or(-1) status.code().unwrap_or(-1)
); );
} }
Ok(()) 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")] #[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> { fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service"); 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")) 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 elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() { let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()? StdCommand::new(&uninstall_path).status()?
@@ -687,7 +666,14 @@ fn uninstall_service() -> Result<()> {
status.code().unwrap_or(-1) 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")] #[cfg(target_os = "linux")]
@@ -702,10 +688,14 @@ fn linux_running_as_root() -> bool {
fn uninstall_service() -> Result<()> { fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service"); 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")) 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_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_owned();
@@ -721,7 +711,14 @@ fn uninstall_service() -> Result<()> {
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?; 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<()> { fn install_service() -> Result<()> {
+14 -19
View File
@@ -241,19 +241,20 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
} }
pub(crate) struct Sysopt { pub(crate) struct Sysopt {
// Owns the proxy target pair; every reader and writer goes through this lock. update_lock: TokioMutex<()>,
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
guard_operation_lock: TokioMutex<()>, guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool, reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>, guard: Arc<RwLock<GuardMonitor>>,
} }
impl Default for Sysopt { impl Default for Sysopt {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())), update_lock: TokioMutex::new(()),
guard_operation_lock: TokioMutex::new(()), guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false), 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)))), 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"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
static BYPASS_SEPARATOR: &str = ","; static BYPASS_SEPARATOR: &str = ",";
#[cfg(target_os = "macos")] #[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() { if custom_bypass.is_empty() {
DEFAULT_BYPASS.into() DEFAULT_BYPASS.into()
} else if use_default { } else if use_default {
@@ -359,7 +361,7 @@ impl Sysopt {
} }
} }
while self.access_guard().read().get_state().is_pendding() { 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. // `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped() !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 /// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition). /// disabled but HTTP still enabled mid-transition).
pub(crate) async fn wait_idle(&self) { pub(crate) async fn wait_idle(&self) {
let _ = self.proxy_state_lock.lock().await; let _ = self.update_lock.lock().await;
} }
/// init the sysproxy /// 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<()> { 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(); let verge = Config::verge().await.latest_arc();
// Configured, not live: this runs while the Core is being started or restarted, and // 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. // asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await; let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?; let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await; let bypass = get_bypass().await;
let (sys_enable, pac_enable, proxy_host, proxy_guard) = ( let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
@@ -432,7 +431,7 @@ impl Sysopt {
); );
let (sys, auto, guard_type) = { 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.host = proxy_host.into();
sys.port = port; sys.port = port;
sys.bypass = bypass.into(); sys.bypass = bypass.into();
@@ -529,10 +528,6 @@ impl Sysopt {
} }
/// reset the sysproxy /// 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<()> { pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self if self
.reset_sysproxy .reset_sysproxy
@@ -544,13 +539,13 @@ impl Sysopt {
defer! { defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst); 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 _guard_operation = self.guard_operation_lock.lock().await;
let drained = self.stop_proxy_guard_locked().await; let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理 // 直接关闭所有代理
let (sys, auto) = { let (sys, auto) = {
let (sys, auto) = &mut *proxy_state; let (sys, auto) = &mut *self.inner_proxy.write();
sys.enable = false; sys.enable = false;
auto.enable = false; auto.enable = false;
(sys.clone(), auto.clone()) (sys.clone(), auto.clone())
+91 -83
View File
@@ -1,4 +1,4 @@
use crate::config::{IProfilePreview, IVerge}; use crate::config::{IProfilePreview, IProfiles, IVerge};
use crate::core::lightweight; use crate::core::lightweight;
use crate::core::tray::menu_def::TrayAction; use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler; use crate::process::AsyncHandler;
@@ -41,7 +41,10 @@ const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
pub const TRAY_ID: &str = "clash-verge-rev-tray"; pub const TRAY_ID: &str = "clash-verge-rev-tray";
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct TrayMenuOptions { struct TrayMenuOptions<'a> {
mode: Option<&'a str>,
system_proxy_enabled: bool,
tun_mode_enabled: bool,
is_lightweight_mode: bool, is_lightweight_mode: bool,
include_proxy_groups: bool, include_proxy_groups: bool,
} }
@@ -201,9 +204,8 @@ impl Tray {
}; };
let verge = Config::verge().await.latest_arc(); let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let system_proxy = verge.enable_system_proxy.unwrap_or(false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.unwrap_or(false);
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
let mode = { let mode = {
Config::clash() Config::clash()
.await .await
@@ -214,29 +216,73 @@ impl Tray {
.unwrap_or("rule") .unwrap_or("rule")
.to_owned() .to_owned()
}; };
let profiles_config = Config::profiles().await;
let profiles_arc = profiles_config.latest_arc();
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode(); let is_lightweight_mode = is_in_lightweight_mode();
logging_error!( let fetch_proxy_groups =
Type::Tray, include_proxy_groups && verge.tray_proxy_groups_display_mode.as_deref() != Some("disable");
tray.set_menu(Some(
create_tray_menu( // TODO: should update tray menu again when it was timeout error
app_handle, let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
Some(mode.as_str()), let proxy_nodes_data =
*system_proxy, tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
*tun_mode, .await
tun_mode_available, .map_or(None, |res| res.ok());
profiles_preview,
TrayMenuOptions { let runtime = Config::runtime().await.latest_arc();
is_lightweight_mode, let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
include_proxy_groups, config
}, .get("proxy-groups")
) .and_then(|groups| groups.as_sequence())
.await?, .map(|groups| {
)) groups
); .iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else {
(None, None)
};
let verge = if fetch_proxy_groups {
Config::verge().await.latest_arc()
} else {
verge
};
let profiles = Config::profiles().await.latest_arc();
let (tx, rx) = tokio::sync::oneshot::channel();
let dispatch_handle = app_handle.clone();
app_handle
.run_on_main_thread(move || {
let _ = tx.send((|| -> Result<()> {
let menu = build_tray_menu(
&dispatch_handle,
TrayMenuOptions {
mode: Some(mode.as_str()),
system_proxy_enabled: system_proxy,
tun_mode_enabled: tun_mode,
is_lightweight_mode,
include_proxy_groups,
},
&verge,
&profiles,
runtime_proxy_groups_order,
proxy_nodes_data,
)?;
logging_error!(Type::Tray, tray.set_menu(Some(menu)));
Ok(())
})());
})
.map_err(|e| anyhow::anyhow!("Failed to dispatch tray menu update: {e}"))?;
rx.await
.map_err(|_| anyhow::anyhow!("Tray menu update returned no result"))??;
logging!(debug, Type::Tray, "托盘菜单更新成功"); logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(()) Ok(())
@@ -422,11 +468,12 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
let mut parts = item.split(','); let mut parts = item.split(',');
match (parts.next(), parts.next()) { match (parts.next(), parts.next()) {
(Some(func), Some(key)) => { (Some(func), Some(key)) => {
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`) // 托盘菜单中的 `accelerator` 属性,在 Linux/Windows 中都不支持小键盘按键的解析
let uppercase_key = key.to_uppercase(); if key.to_uppercase().contains("NUMPAD") {
let unsupported = None
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD")); } else {
if unsupported { None } else { Some((func, key)) } Some((func, key))
}
} }
_ => None, _ => None,
} }
@@ -584,62 +631,22 @@ fn create_proxy_menu_item(
Ok((proxies_submenu, inline_proxy_items)) Ok((proxies_submenu, inline_proxy_items))
} }
async fn create_tray_menu( fn build_tray_menu(
app_handle: &AppHandle, app_handle: &AppHandle,
mode: Option<&str>, options: TrayMenuOptions<'_>,
system_proxy_enabled: bool, verge_settings: &IVerge,
tun_mode_enabled: bool, profiles_config: &IProfiles,
tun_mode_available: bool, proxy_group_order_map: Option<HashMap<String, usize>>,
profiles_preview: Vec<IProfilePreview<'_>>, proxy_nodes_data: Option<Proxies>,
options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> { ) -> Result<tauri::menu::Menu<Wry>> {
let current_proxy_mode = mode.unwrap_or(""); let current_proxy_mode = options.mode.unwrap_or("");
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime = Config::runtime().await.latest_arc();
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else {
(None, None)
};
if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc();
}
let tray_proxy_groups_display_mode = verge_settings let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode .tray_proxy_groups_display_mode
.as_deref() .as_deref()
.unwrap_or("default"); .unwrap_or("default");
let include_proxy_groups = let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
let proxy_group_order_map = runtime_proxy_groups_order;
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false); let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
@@ -647,7 +654,8 @@ async fn create_tray_menu(
let hotkeys = create_hotkeys(&verge_settings.hotkeys); let hotkeys = create_hotkeys(&verge_settings.hotkeys);
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?; let profile_menu_items: Vec<CheckMenuItem<Wry>> =
create_profile_menu_item(app_handle, profiles_config.profiles_preview().unwrap_or_default())?;
let texts = MenuTexts::new(); let texts = MenuTexts::new();
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
@@ -738,7 +746,7 @@ async fn create_tray_menu(
MenuIds::SYSTEM_PROXY, MenuIds::SYSTEM_PROXY,
&texts.system_proxy, &texts.system_proxy,
true, true,
system_proxy_enabled, options.system_proxy_enabled,
hotkeys.get("toggle_system_proxy").copied(), hotkeys.get("toggle_system_proxy").copied(),
)?; )?;
@@ -747,7 +755,7 @@ async fn create_tray_menu(
MenuIds::TUN_MODE, MenuIds::TUN_MODE,
&texts.tun_mode, &texts.tun_mode,
tun_mode_available, tun_mode_available,
tun_mode_enabled, options.tun_mode_enabled,
hotkeys.get("toggle_tun_mode").copied(), hotkeys.get("toggle_tun_mode").copied(),
)?; )?;
+11 -2
View File
@@ -1,7 +1,10 @@
#![cfg(target_os = "windows")] #![cfg(target_os = "windows")]
use crate::{core::service, utils::dirs}; use crate::utils::dirs;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::process::Command as StdCommand;
pub fn invoke_uwptools() -> Result<()> { pub fn invoke_uwptools() -> Result<()> {
let resource_dir = dirs::app_resources_dir()?; let resource_dir = dirs::app_resources_dir()?;
@@ -11,7 +14,13 @@ pub fn invoke_uwptools() -> Result<()> {
bail!("enableLoopback exe not found"); 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(()) 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
}
+67 -7
View File
@@ -22,6 +22,12 @@ pub enum ChainType {
Groups(SeqMap), Groups(SeqMap),
} }
#[derive(Debug, Clone)]
pub enum ChainSupport {
ClashMeta,
ClashMetaAlpha,
}
// Helper trait to allow async conversion // Helper trait to allow async conversion
pub trait AsyncChainItemFrom { pub trait AsyncChainItemFrom {
async fn from_async(item: &PrfItem) -> Option<ChainItem>; async fn from_async(item: &PrfItem) -> Option<ChainItem>;
@@ -47,16 +53,70 @@ impl AsyncChainItemFrom for Option<ChainItem> {
uid, uid,
data: ChainType::Merge(help::read_mapping(&path).await.ok()?), data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}), }),
"rules" | "proxies" | "groups" => { "rules" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?; let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
let data = match itype { Some(ChainItem {
"rules" => ChainType::Rules(seq_map), uid,
"proxies" => ChainType::Proxies(seq_map), data: ChainType::Rules(seq_map),
_ => ChainType::Groups(seq_map), })
}; }
Some(ChainItem { uid, data }) "proxies" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Proxies(seq_map),
})
}
"groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Groups(seq_map),
})
} }
_ => None, _ => None,
} }
} }
} }
impl ChainItem {
/// 内建支持一些脚本
pub fn builtin() -> Vec<(ChainSupport, Self)> {
// meta 的一些处理
let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
// meta 的一些处理
let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
vec![
(ChainSupport::ClashMeta, hy_alpn),
(ChainSupport::ClashMeta, meta_guard),
(ChainSupport::ClashMetaAlpha, hy_alpn_alpha),
(ChainSupport::ClashMetaAlpha, meta_guard_alpha),
]
}
pub fn to_script<U: Into<String>, D: Into<String>>(uid: U, data: D) -> Self {
Self {
uid: uid.into(),
data: ChainType::Script(data.into()),
}
}
}
impl ChainSupport {
pub fn is_support(&self, core: Option<&String>) -> bool {
match core {
Some(core) => matches!(
(self, core.as_str()),
(Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha")
),
None => true,
}
}
}
+12 -1
View File
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
pub fn use_lowercase(config: &Mapping) -> Mapping { pub fn use_lowercase(config: &Mapping) -> Mapping {
let mut lowercased = Mapping::new(); let mut lowercased = Mapping::new();
for (key, value) in config.iter() { for (key, value) in config.into_iter() {
if let Some(key_str) = key.as_str() { if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value.clone()); lowercased.insert(lowercase_key(key_str), value.clone());
} }
@@ -35,6 +35,17 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
lowercased lowercased
} }
pub fn use_lowercase_owned(config: Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value);
}
}
lowercased
}
pub fn use_sort(mut config: Mapping) -> Mapping { pub fn use_sort(mut config: Mapping) -> Mapping {
let mut sorted = Mapping::new(); let mut sorted = Mapping::new();
HANDLE_FIELDS.into_iter().for_each(|key| { HANDLE_FIELDS.into_iter().for_each(|key| {
+127 -64
View File
@@ -8,7 +8,7 @@ mod tun;
use self::{ use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType}, chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase, use_sort}, field::{use_keys, use_lowercase_owned, use_sort},
merge::use_merge, merge::use_merge,
script::use_script, script::use_script,
seq::{SeqMap, use_seq}, seq::{SeqMap, use_seq},
@@ -34,6 +34,7 @@ type ResultLog = Vec<(String, String)>;
#[derive(Debug)] #[derive(Debug)]
struct ConfigValues { struct ConfigValues {
clash_config: Mapping, clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool, enable_tun: bool,
enable_builtin: bool, enable_builtin: bool,
socks_enabled: bool, socks_enabled: bool,
@@ -127,7 +128,8 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
let dns_settings = verge_arc.dns_settings_for(profile_uid); let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation; let dns_override_confirmation = dns_settings.confirmation;
let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false), enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true), enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false), verge_socks_enabled.unwrap_or(false),
@@ -146,6 +148,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
ConfigValues { ConfigValues {
clash_config, clash_config,
clash_core,
enable_tun, enable_tun,
enable_builtin, enable_builtin,
socks_enabled, socks_enabled,
@@ -196,25 +199,35 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let name = current_item.name.clone().unwrap_or_default(); let name = current_item.name.clone().unwrap_or_default();
let ProfileItems {
merge_item: default_merge,
script_item: default_script,
rules_item: default_rules,
proxies_item: default_proxies,
groups_item: default_groups,
global_merge: default_global_merge,
global_script: default_global_script,
..
} = ProfileItems::default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!( let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,), chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,), uid: "".into(),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,), data: ChainType::Merge(Mapping::new()),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,), },),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,), chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,), uid: "".into(),
chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,), data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
); );
Ok(ProfileItems { Ok(ProfileItems {
@@ -230,16 +243,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
}) })
} }
async fn process_override_items( async fn process_global_items(
mut config: Mapping, mut config: Mapping,
mut exists_keys: Vec<String>, mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>, mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem, global_merge: ChainItem,
script_item: ChainItem, global_script: ChainItem,
profile_name: &String, profile_name: &String,
authoritative: &AuthoritativeFields, authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) { ) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data { if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge)); exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config); let before = authoritative.current(&config);
config = use_merge(&merge, config); config = use_merge(&merge, config);
@@ -249,11 +262,11 @@ async fn process_override_items(
.map(discarded_note) .map(discarded_note)
.collect(); .collect();
if !notes.is_empty() { if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes); result_map.entry(global_merge.uid).or_default().extend(notes);
} }
} }
if let ChainType::Script(script) = script_item.data { if let ChainType::Script(script) = global_script.data {
let before = authoritative.current(&config); let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys); exists_keys.extend(changed_keys);
@@ -264,7 +277,7 @@ async fn process_override_items(
.into_iter() .into_iter()
.map(discarded_note), .map(discarded_note),
); );
result_map.insert(script_item.uid, logs); result_map.insert(global_script.uid, logs);
} }
(config, exists_keys, result_map) (config, exists_keys, result_map)
@@ -531,6 +544,46 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
config config
} }
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure. /// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config( fn merge_default_config(
mut config: Mapping, mut config: Mapping,
@@ -602,12 +655,25 @@ fn merge_default_config(
config config
} }
fn apply_builtin_scripts(mut config: Mapping, enable_builtin: bool) -> Mapping { fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
if enable_builtin { if enable_builtin {
// The JS path saw a lowercased view; lowercase once, only when the builtin runs. let items: Vec<_> = ChainItem::builtin()
config = use_lowercase(&config); .into_iter()
logging!(debug, Type::Core, "run builtin script verge_hy_alpn"); .filter(|(s, _)| s.is_support(clash_core.as_ref()))
config = builtin_hy_alpn(config); .map(|(_, c)| c)
.collect();
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
} }
config config
@@ -627,6 +693,14 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
config config
} }
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping { fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in // built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"]; const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
@@ -805,6 +879,7 @@ pub async fn enhance(
let cfg_vals = get_config_values(profile_uid).await; let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues { let ConfigValues {
clash_config, clash_config,
clash_core,
enable_tun, enable_tun,
enable_builtin, enable_builtin,
socks_enabled, socks_enabled,
@@ -854,13 +929,13 @@ pub async fn enhance(
tproxy_enabled, tproxy_enabled,
); );
let config = apply_builtin_scripts(config, enable_builtin); let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun); let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await; let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings); let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_override_items( let (config, exists_keys, result_map) = process_global_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
@@ -871,7 +946,7 @@ pub async fn enhance(
) )
.await; .await;
let (config, exists_keys, result_map) = process_override_items( let (config, exists_keys, result_map) = process_profile_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
@@ -1353,7 +1428,7 @@ mod authoritative_field_tests {
mod tests { mod tests {
use super::{ use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address, AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_override_items, use_keys, process_global_items, process_profile_items, use_keys,
}; };
use std::collections::HashMap; use std::collections::HashMap;
@@ -1401,28 +1476,22 @@ mod tests {
data: ChainType::Merge(merge.clone()), data: ChainType::Merge(merge.clone()),
}; };
let profile_name = "test-profile".into(); let profile_name = "test-profile".into();
let (config, keys, logs) = process_override_items( let (config, keys, logs) = process_global_items(
config, config,
vec![], vec![],
HashMap::new(), HashMap::new(),
merge_item("global-merge"), merge_item("global-merge"),
ChainItem { ChainItem::to_script("global-script", script),
uid: "global-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name, &profile_name,
&authoritative, &authoritative,
) )
.await; .await;
let (config, _, logs) = process_override_items( let (config, _, logs) = process_profile_items(
config, config,
keys, keys,
logs, logs,
merge_item("profile-merge"), merge_item("profile-merge"),
ChainItem { ChainItem::to_script("profile-script", script),
uid: "profile-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name, &profile_name,
&authoritative, &authoritative,
) )
@@ -1467,19 +1536,16 @@ mod tests {
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}", nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
)), )),
}; };
let global_script = ChainItem { let global_script = ChainItem::to_script(
uid: "Script".into(), "Script",
data: ChainType::Script( r#"function main(config) {
r#"function main(config) {
config["global-script-wins"] = "global-script"; config["global-script-wins"] = "global-script";
config["profile-merge-wins"] = "global-script"; config["profile-merge-wins"] = "global-script";
config["profile-script-wins"] = "global-script"; config["profile-script-wins"] = "global-script";
config.nested.winner = "global-script"; config.nested.winner = "global-script";
return config; return config;
}"# }"#,
.into(), );
),
};
let profile_merge = ChainItem { let profile_merge = ChainItem {
uid: "profile-merge".into(), uid: "profile-merge".into(),
data: ChainType::Merge(mapping( data: ChainType::Merge(mapping(
@@ -1487,21 +1553,18 @@ mod tests {
nested: {winner: profile-merge}}", nested: {winner: profile-merge}}",
)), )),
}; };
let profile_script = ChainItem { let profile_script = ChainItem::to_script(
uid: "profile-script".into(), "profile-script",
data: ChainType::Script( r#"function main(config) {
r#"function main(config) {
config["profile-script-wins"] = "profile-script"; config["profile-script-wins"] = "profile-script";
config.nested.winner = "profile-script"; config.nested.winner = "profile-script";
return config; return config;
}"# }"#,
.into(), );
),
};
let profile_name = "test-profile".into(); let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new()); let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_override_items( let (config, exists_keys, result_map) = process_global_items(
config, config,
exists_keys, exists_keys,
HashMap::new(), HashMap::new(),
@@ -1511,7 +1574,7 @@ mod tests {
&authoritative, &authoritative,
) )
.await; .await;
let (config, exists_keys, _) = process_override_items( let (config, exists_keys, _) = process_profile_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::{process::AsyncHandler, utils::tmpl}; use crate::{process::AsyncHandler, utils::tmpl};
use super::field::use_lowercase; use super::field::{use_lowercase, use_lowercase_owned};
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute}; use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
@@ -22,7 +22,7 @@ type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>)
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult { pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
// The template is identity-plus-lowercasing; skip the Boa round-trip. // The template is identity-plus-lowercasing; skip the Boa round-trip.
if script == tmpl::ITEM_SCRIPT { if script == tmpl::ITEM_SCRIPT {
return (use_lowercase(&config), vec![], vec![]); return (use_lowercase_owned(config), vec![], vec![]);
} }
let json = match serde_json::to_string(&use_lowercase(&config)) { let json = match serde_json::to_string(&use_lowercase(&config)) {
@@ -173,7 +173,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
} }
match parse_json_safely(&result) { match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()), Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()),
Err(err) => { Err(err) => {
logging!( logging!(
error, error,
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("fake-ip"); .unwrap_or("fake-ip");
if current_mode == "fake-ip" { if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) {
revise!(dns_val, "enable", true); revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val); revise!(dns_val, "ipv6", ipv6_val);
+50 -53
View File
@@ -33,9 +33,6 @@ async fn finalize_restored_verge_config(
webdav_username: Option<String>, webdav_username: Option<String>,
webdav_password: Option<String>, webdav_password: Option<String>,
) -> Result<()> { ) -> Result<()> {
// The lock must cover every restored layer; an unlocked apply races a concurrently staged draft.
let config_write = Config::lock_config_write().await;
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults. // A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?; let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
restored.webdav_url = webdav_url; restored.webdav_url = webdav_url;
@@ -46,42 +43,43 @@ async fn finalize_restored_verge_config(
let restored_clash = IClashTemp::new().await; let restored_clash = IClashTemp::new().await;
let clash_draft = Config::clash().await; let clash_draft = Config::clash().await;
clash_draft.edit_draft(|d| { clash_draft.edit_draft(|d| {
*d = restored_clash; *d = restored_clash.clone();
}); });
clash_draft.apply(); clash_draft.apply();
let restored_profiles = IProfiles::new().await; let restored_profiles = IProfiles::new().await;
let profiles_draft = Config::profiles().await; let profiles_draft = Config::profiles().await;
profiles_draft.edit_draft(|d| { profiles_draft.edit_draft(|d| {
*d = restored_profiles; *d = restored_profiles.clone();
}); });
profiles_draft.apply(); profiles_draft.apply();
let verge_draft = Config::verge().await; let verge_draft = Config::verge().await;
verge_draft.edit_draft(|d| { {
*d = restored.clone(); // Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads.
}); let _config_write = Config::lock_config_write().await;
verge_draft.apply(); verge_draft.edit_draft(|d| {
*d = restored.clone();
});
verge_draft.apply();
// Turn it off here; a failing core side effect below would otherwise skip this step. // Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() { if !restored.enable_system_proxy.unwrap_or_default() {
let result = async { let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await; let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?; proxy_control::apply().await?;
proxy_control::refresh_guard().await proxy_control::refresh_guard().await
} }
.await; .await;
if let Err(err) = result { if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}"); logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) { if SystemProxyStateUnknown::is_in(&err) {
return Err(err); return Err(err);
}
} }
} }
} }
// `patch_verge` re-acquires the configuration write lock.
drop(config_write);
// Run configuration side effects without rewriting the already-restored file. // Run configuration side effects without rewriting the already-restored file.
if let Err(err) = super::patch_verge(&restored, true).await { if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}"); logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
@@ -136,35 +134,14 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
}) })
} }
/// Backup creation strips the WebDAV credentials, so the live ones are kept.
async fn restore_backup_archive(path: PathBuf) -> Result<()> {
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let home_dir = app_home_dir()?;
AsyncHandler::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&path)?;
let mut zip = zip::ZipArchive::new(file)?;
zip.extract(&home_dir)?;
Ok(())
})
.await
.map_err(|join| anyhow!("backup extraction task failed: {join}"))??;
// Held for the config-data transition only; extraction is plain filesystem work.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))] #[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
pub async fn restore_webdav_backup(filename: String) -> Result<()> { pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge().await;
let verge_data = verge.latest_arc();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir() let backup_storage_path = app_home_dir()
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))? .map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
.join(filename.as_str()); .join(filename.as_str());
@@ -181,7 +158,12 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
err err
})?; })?;
let res = restore_backup_archive(backup_storage_path.clone()).await; let value = backup_storage_path.clone();
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
let _ = backup_storage_path.remove_if_exists().await; let _ = backup_storage_path.remove_if_exists().await;
res res
} }
@@ -350,7 +332,22 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
return Err(anyhow!("Backup file not found: {}", filename)); return Err(anyhow!("Backup file not found: {}", filename));
} }
restore_backup_archive(target_path).await let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?;
Ok(())
} }
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))] #[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
+45 -12
View File
@@ -3,7 +3,7 @@ use crate::core::notify::{Refresh, announce};
use crate::{ use crate::{
config::{Config, MixedPort}, config::{Config, MixedPort},
core::{CoreManager, handle, tray}, core::{CoreManager, handle, tray},
feat::run_exit_cleanup_flow, feat::clean_async,
process::AsyncHandler, process::AsyncHandler,
utils, utils,
}; };
@@ -14,8 +14,15 @@ use smartstring::alias::String;
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions"))); let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.expect("Failed to set TLS versions")
.with_root_certificates(root_store)
.with_no_client_auth();
Arc::new(config)
});
pub async fn restart_clash_core() { pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await { match CoreManager::global().restart_core().await {
@@ -31,9 +38,38 @@ pub async fn restart_clash_core() {
pub async fn restart_app() { pub async fn restart_app() {
logging!(debug, Type::System, "启动重启应用流程"); logging!(debug, Type::System, "启动重启应用流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await; handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await;
let cleanup_result = clean_async().await;
logging!(
info,
Type::System,
"资源清理完成,退出代码: {}",
if cleanup_result.all_success { 0 } else { 1 }
);
if !cleanup_result.core_stopped { if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppRestartCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return; return;
} }
@@ -67,14 +103,11 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
} }
let clash = Config::clash().await; let clash = Config::clash().await;
// Serialize with patch transactions: an unlocked commit can flush a staged draft mid-rollback. clash.edit_draft(|d| d.patch_config(&mapping));
let save_result = { clash.apply();
let _config_write = Config::lock_config_write().await;
clash.edit_draft(|d| d.patch_config(&mapping)); let clash_data = clash.data_arc();
clash.apply(); if clash_data.save_config().await.is_ok() {
clash.data_arc().save_config().await
};
if save_result.is_ok() {
announce(Refresh::Clash); announce(Refresh::Clash);
tray::Tray::global().update_menu_and_icon().await; tray::Tray::global().update_menu_and_icon().await;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
/// Apply a patch without post-update reconciliation. /// Apply a patch without post-update reconciliation.
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> { pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
let config_write = Config::lock_config_write().await; let config_write = Config::try_lock_config_write()?;
apply_verge_patch_locked(&config_write, patch, not_save_file).await apply_verge_patch_locked(&config_write, patch, not_save_file).await
} }
+15
View File
@@ -47,11 +47,26 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
record_switched_node(group_name, proxy_name).await; record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies); announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await; let _ = tray::Tray::global().update_menu().await;
return;
} }
Err(err) => { Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}"); logging!(error, Type::Tray, "切换代理失败: {err:?}");
} }
} }
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "代理切换最终失败: {err:?}");
}
}
} }
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> { async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
+6 -10
View File
@@ -130,7 +130,9 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}"); logging!(info, Type::Window, "Window toggle result: {result:?}");
} }
pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult { pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await; Config::apply_all_and_save_file().await;
@@ -160,18 +162,11 @@ pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> C
} }
} }
handle::Handle::notice( handle::Handle::notice(
stop_failed_notice, NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.clone().unwrap_or_default(), cleanup_result.stop_error.unwrap_or_default(),
); );
} }
cleanup_result
}
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await;
utils::server::shutdown_embedded_server(); utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
@@ -321,6 +316,7 @@ pub async fn hide() {
{ {
let _ = window.hide(); let _ = window.hide();
} }
handle::Handle::global().set_activation_policy_accessory();
} }
#[cfg(test)] #[cfg(test)]
+3
View File
@@ -289,6 +289,9 @@ pub fn run() -> std::process::ExitCode {
} }
pub fn handle_window_close(api: &tauri::WindowEvent) { pub fn handle_window_close(api: &tauri::WindowEvent) {
#[cfg(target_os = "macos")]
handle::Handle::global().set_activation_policy_accessory();
if core::handle::Handle::global().is_exiting() { if core::handle::Handle::global().is_exiting() {
return; return;
} }
+5 -2
View File
@@ -354,6 +354,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
Value::String("*.arpa".into()), Value::String("*.arpa".into()),
Value::String("time.*.com".into()), Value::String("time.*.com".into()),
Value::String("ntp.*.com".into()), Value::String("ntp.*.com".into()),
Value::String("time.*.com".into()),
Value::String("+.market.xiaomi.com".into()), Value::String("+.market.xiaomi.com".into()),
Value::String("localhost.ptlogin2.qq.com".into()), Value::String("localhost.ptlogin2.qq.com".into()),
Value::String("*.msftncsi.com".into()), Value::String("*.msftncsi.com".into()),
@@ -508,9 +509,11 @@ pub async fn init_resources() -> Result<()> {
let app_dir = dirs::app_home_dir()?; let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir()?; let res_dir = dirs::app_resources_dir()?;
// app_home is created by `ensure_directories` in `init_config`, which always runs first. if !app_dir.exists() {
std::mem::drop(fs::create_dir_all(&app_dir).await);
}
if !res_dir.exists() { if !res_dir.exists() {
fs::create_dir_all(&res_dir).await?; std::mem::drop(fs::create_dir_all(&res_dir).await);
} }
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"]; let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
+41 -41
View File
@@ -48,17 +48,6 @@ enum TlsRootMode {
StaticWebpkiRoots, StaticWebpkiRoots,
} }
/// ALPN stays unset: raw TLS connectors offer none, while reqwest needs the h2 offer.
pub(crate) fn build_webpki_rooted_tls_config() -> Result<rustls::ClientConfig> {
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(config)
}
pub struct NetworkManager; pub struct NetworkManager;
impl NetworkManager { impl NetworkManager {
@@ -113,7 +102,13 @@ impl NetworkManager {
} }
fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> { fn build_static_webpki_tls_config() -> Result<rustls::ClientConfig> {
let mut config = build_webpki_rooted_tls_config()?; let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config =
rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(config) Ok(config)
@@ -160,23 +155,6 @@ impl NetworkManager {
detail.contains("protocolversion") || detail.contains("protocol version") detail.contains("protocolversion") || detail.contains("protocol version")
} }
/// `allow_fallback` stays false when the caller already disabled certificate verification.
async fn request_with_static_webpki_fallback<T, F, Fut>(allow_fallback: bool, mut request: F) -> Result<T>
where
F: FnMut(TlsRootMode) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
match request(TlsRootMode::PlatformVerifier).await {
Ok(value) => Ok(value),
Err(err) if allow_fallback && Self::should_retry_with_static_webpki_roots(&err) => {
request(TlsRootMode::StaticWebpkiRoots).await.map_err(|fallback_err| {
fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
})
}
Err(err) => Err(err),
}
}
async fn get_with_tls_mode( async fn get_with_tls_mode(
&self, &self,
url: &str, url: &str,
@@ -286,21 +264,34 @@ impl NetworkManager {
user_agent: Option<String>, user_agent: Option<String>,
accept_invalid_certs: bool, accept_invalid_certs: bool,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
Self::request_with_static_webpki_fallback(!accept_invalid_certs, |tls_root_mode| { let platform_result = self
let user_agent = user_agent.clone(); .get_with_tls_mode(
async move { url,
self.get_with_tls_mode( proxy_type,
timeout_secs,
user_agent.clone(),
accept_invalid_certs,
TlsRootMode::PlatformVerifier,
)
.await;
match platform_result {
Ok(response) => Ok(response),
Err(err) if !accept_invalid_certs && Self::should_retry_with_static_webpki_roots(&err) => self
.get_with_tls_mode(
url, url,
proxy_type, proxy_type,
timeout_secs, timeout_secs,
user_agent, user_agent,
accept_invalid_certs, accept_invalid_certs,
tls_root_mode, TlsRootMode::StaticWebpkiRoots,
) )
.await .await
} .map_err(|fallback_err| {
}) fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
.await }),
Err(err) => Err(err),
}
} }
async fn get_bytes_with_tls_mode( async fn get_bytes_with_tls_mode(
@@ -349,10 +340,19 @@ impl NetworkManager {
timeout_secs: Option<u64>, timeout_secs: Option<u64>,
max_bytes: usize, max_bytes: usize,
) -> Result<Vec<u8>> { ) -> Result<Vec<u8>> {
Self::request_with_static_webpki_fallback(true, |tls_root_mode| async move { let platform_result = self
self.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, tls_root_mode) .get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::PlatformVerifier)
.await;
match platform_result {
Ok(body) => Ok(body),
Err(err) if Self::should_retry_with_static_webpki_roots(&err) => self
.get_bytes_with_tls_mode(url, proxy_type, timeout_secs, max_bytes, TlsRootMode::StaticWebpkiRoots)
.await .await
}) .map_err(|fallback_err| {
.await fallback_err.context("static webpki roots fallback failed after platform TLS verifier failed")
}),
Err(err) => Err(err),
}
} }
} }
+14
View File
@@ -1,6 +1,8 @@
use crate::core::notify::NoticeStatus; use crate::core::notify::NoticeStatus;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Result;
use crate::{ use crate::{
config::Config, config::Config,
core::{ core::{
@@ -95,6 +97,18 @@ async fn resolve_setup() {
resolve_done(); resolve_done();
} }
pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
CoreManager::global().stop_core().await?;
#[cfg(target_os = "macos")]
{
use dns::restore_public_dns;
restore_public_dns().await?;
}
Ok(())
}
fn init_scheme() { fn init_scheme() {
logging_error!(Type::Setup, init::init_scheme()); logging_error!(Type::Setup, init::init_scheme());
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.5.10", "version": "2.5.9",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": { "bundle": {
"active": true, "active": true,
-24
View File
@@ -21,27 +21,3 @@ These rules supplement the repository's root `AGENTS.md`.
- **Command ownership.** `services` owns backend command invocation and transport - **Command ownership.** `services` owns backend command invocation and transport
wrappers. Components and hooks use those boundaries; plugin mutations follow wrappers. Components and hooks use those boundaries; plugin mutations follow
the same intent ownership as backend commands. the same intent ownership as backend commands.
- **State liveness.** Every `useState`/`useRef` member has a reachable write
that differs from its initial value and a read that observes the difference.
A value that can only ever hold its initial value is a constant: declare the
constant and delete the state. A ref nothing reads is deleted, not kept.
- **Surface liveness.** Anything a module exposes — props, parameters, context
members, imperative-handle methods — ships with at least one external caller
in the same change. "Reserved for future use" fields, `_`-prefixed params
that callers still pass, and error or callback channels that every receiver
ignores are removed, not maintained.
- **Path liveness.** A fallback or branch names the concrete runtime condition
that reaches it. If no supported runtime can reach it, the branch is not
added — or it replaces the primary path. Constant subexpressions and cleanup
of resources that cannot exist yet are simplified away.
- **Effect economy.** One user interaction produces one state write and one
command. A handler does not re-apply a value its wrapper already applied,
and an effect does not re-run a refresh another branch of the same effect
just ran.
- **Mount economy.** Components that issue commands or subscriptions on mount
are mounted only where their UI is reachable. A dialog no rendered control
can open stays unmounted, not mounted-and-idle.
- **Resource bounds.** Every cache key, timer and subscription a feature
allocates is reused or released on refresh; refresh never allocates an
unbounded sequence of new keys. Retries re-create per-attempt resources so
a retry can actually succeed.
+30 -13
View File
@@ -22,16 +22,23 @@ interface BaseSplitChipEditorProps {
error?: boolean error?: boolean
helperText?: ReactNode helperText?: ReactNode
placeholder?: string placeholder?: string
rows?: number
separator?: string separator?: string
splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean
ariaLabel?: string ariaLabel?: string
addLabel?: ReactNode
emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode renderHeader?: (modeToggle: ReactNode) => ReactNode
} }
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/ const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string) => const splitValue = (value: string, splitPattern: RegExp) =>
value value
.split(DEFAULT_SPLIT_PATTERN) .split(splitPattern)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
@@ -42,25 +49,35 @@ export const BaseSplitChipEditor = ({
error = false, error = false,
helperText, helperText,
placeholder, placeholder,
rows = 4,
separator = ',', separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = 'visual',
showModeToggle = true,
ariaLabel, ariaLabel,
addLabel,
emptyLabel,
modeLabels,
renderHeader, renderHeader,
}: BaseSplitChipEditorProps) => { }: BaseSplitChipEditorProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual') const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const resolvedLabels = useMemo( const resolvedLabels = useMemo(
() => ({ () => ({
visual: t('shared.editorModes.visualization'), visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: t('shared.editorModes.advanced'), advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: t('shared.actions.new'), add: addLabel ?? t('shared.actions.new'),
empty: t('shared.statuses.empty'), empty: emptyLabel ?? t('shared.statuses.empty'),
}), }),
[t], [t, modeLabels, addLabel, emptyLabel],
) )
const values = useMemo(() => splitValue(value), [value]) const values = useMemo(
() => splitValue(value, splitPattern),
[value, splitPattern],
)
const items = useMemo(() => { const items = useMemo(() => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
@@ -75,7 +92,7 @@ export const BaseSplitChipEditor = ({
}, [values]) }, [values])
const handleAddDraft = () => { const handleAddDraft = () => {
const nextValues = splitValue(draft) const nextValues = splitValue(draft, splitPattern)
if (!nextValues.length) { if (!nextValues.length) {
return return
} }
@@ -96,7 +113,7 @@ export const BaseSplitChipEditor = ({
const resolvedAriaLabel = const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined) ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = ( const modeToggle = showModeToggle ? (
<Tooltip title={toggleLabel}> <Tooltip title={toggleLabel}>
<IconButton <IconButton
size="small" size="small"
@@ -111,7 +128,7 @@ export const BaseSplitChipEditor = ({
<ToggleIcon fontSize="small" /> <ToggleIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
) ) : null
return ( return (
<> <>
@@ -185,7 +202,7 @@ export const BaseSplitChipEditor = ({
disabled={disabled} disabled={disabled}
size="small" size="small"
multiline multiline
rows={4} rows={rows}
sx={{ width: '100%' }} sx={{ width: '100%' }}
value={value} value={value}
helperText={helperText} helperText={helperText}
+9 -3
View File
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
[getProfileStorageKey], [getProfileStorageKey],
) )
const { select } = useProxySelection({ const { handleSelectChange } = useProxySelection({
onSuccess: () => { onSuccess: () => {
refreshProxy() refreshProxy()
}, },
@@ -766,9 +766,15 @@ export const CurrentProxyCard = () => {
const previousProxy = selectedGroup.now const previousProxy = selectedGroup.now
const nextName = option.member.ref.name const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes. // The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!) handleSelectChange(
selectedGroup.name,
previousProxy!,
selectedGroup.fixed!,
)({
target: { value: nextName },
})
}, },
[isDirectMode, select, selectedGroup, unsortedProxyOptions], [handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
) )
const goToProxies = useCallback(() => { const goToProxies = useCallback(() => {
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor' import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { debugLog } from '@/utils/debug'
import parseTraffic from '@/utils/parse-traffic' import parseTraffic from '@/utils/parse-traffic'
import { import {
formatTrafficHourMinute, formatTrafficHourMinute,
@@ -22,7 +23,14 @@ import {
isSameTrafficData, isSameTrafficData,
} from '@/utils/traffic-sampler' } from '@/utils/traffic-sampler'
interface ITrafficItem {
up: number
down: number
timestamp?: number
}
export interface EnhancedCanvasTrafficGraphRef { export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void
toggleStyle: () => void toggleStyle: () => void
} }
@@ -134,6 +142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
[], [],
) )
const debounceTimeoutRef = useRef<number | null>(null) const debounceTimeoutRef = useRef<number | null>(null)
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
const colors = useMemo( const colors = useMemo(
() => ({ () => ({
@@ -174,6 +183,8 @@ export const EnhancedCanvasTrafficGraph = memo(
if (displayData.length === 0) { if (displayData.length === 0) {
lastDataTimestampRef.current = 0 lastDataTimestampRef.current = 0
dataStaleRef.current = false dataStaleRef.current = false
// eslint-disable-next-line @eslint-react/set-state-in-effect
setCurrentFPS(GRAPH_CONFIG.targetFPS)
return return
} }
@@ -189,11 +200,18 @@ export const EnhancedCanvasTrafficGraph = memo(
} }
}, [displayData]) }, [displayData])
const handleFocusStateChange = useCallback((focused: boolean) => { const handleFocusStateChange = useCallback(
isWindowFocusedRef.current = focused (focused: boolean) => {
isWindowFocusedRef.current = focused
scheduleDrawGraphRef.current() if (focused || !pause_render_traffic_stats_on_blur) {
}, []) setCurrentFPS(GRAPH_CONFIG.targetFPS)
}
scheduleDrawGraphRef.current()
},
[pause_render_traffic_stats_on_blur],
)
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') { if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -666,13 +684,19 @@ export const EnhancedCanvasTrafficGraph = memo(
ctx.beginPath() ctx.beginPath()
ctx.moveTo(getX(0), getY(0)) ctx.moveTo(getX(0), getY(0))
for (let i = 1; i < data.length; i++) { if (chartStyle === 'bezier') {
const currentX = getX(i) for (let i = 1; i < data.length; i++) {
const currentY = getY(i) const currentX = getX(i)
const nextIndex = Math.min(i + 1, lastIndex) const currentY = getY(i)
const controlX = (currentX + getX(nextIndex)) / 2 const nextIndex = Math.min(i + 1, lastIndex)
const controlY = (currentY + getY(nextIndex)) / 2 const controlX = (currentX + getX(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY) const controlY = (currentY + getY(nextIndex)) / 2
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
}
} else {
for (let i = 1; i < data.length; i++) {
ctx.lineTo(getX(i), getY(i))
}
} }
ctx.lineTo(getX(lastIndex), height - padding.bottom) ctx.lineTo(getX(lastIndex), height - padding.bottom)
@@ -960,7 +984,21 @@ export const EnhancedCanvasTrafficGraph = memo(
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier')) setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
}, []) }, [])
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle]) const appendData = useCallback((data: ITrafficItem) => {
debugLog(
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
data,
)
}, [])
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
)
const getTimeRangeText = useCallback(() => { const getTimeRangeText = useCallback(() => {
return t('home.components.traffic.patterns.minutes', { return t('home.components.traffic.patterns.minutes', {
@@ -1104,7 +1142,7 @@ export const EnhancedCanvasTrafficGraph = memo(
{t('home.components.traffic.diagnostics', { {t('home.components.traffic.diagnostics', {
points: displayData.length, points: displayData.length,
compressed: samplerStats.compressedBufferSize, compressed: samplerStats.compressedBufferSize,
fps: GRAPH_CONFIG.targetFPS, fps: currentFPS,
})} })}
</Box> </Box>
+6 -55
View File
@@ -3,14 +3,10 @@ import {
ArrowUpwardRounded, ArrowUpwardRounded,
CloudDownloadRounded, CloudDownloadRounded,
CloudUploadRounded, CloudUploadRounded,
ErrorOutlineRounded,
LinkRounded, LinkRounded,
MemoryRounded, MemoryRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Alert,
Box,
Button,
Grid, Grid,
PaletteColor, PaletteColor,
Paper, Paper,
@@ -19,9 +15,9 @@ import {
useTheme, useTheme,
} from '@mui/material' } from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from 'react' import { ReactNode, memo, useMemo, useRef } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionSummaryData } from '@/hooks/use-connection-data' import { useConnectionSummaryData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data' import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data' import { useTrafficData } from '@/hooks/use-traffic-data'
@@ -131,50 +127,6 @@ const CompactStatCard = memo(
// 添加显示名称 // 添加显示名称
CompactStatCard.displayName = 'CompactStatCard' CompactStatCard.displayName = 'CompactStatCard'
const fallbackSx = {
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
}
const reloadPage = () => window.location.reload()
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
const { t } = useTranslation()
return (
<Box sx={fallbackSx}>
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Alert>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
{t('shared.actions.retry')}
</Button>
<Button variant="outlined" size="small" onClick={reloadPage}>
{t('shared.actions.refreshPage')}
</Button>
</Box>
</Box>
)
}
export const EnhancedTrafficStats = () => { export const EnhancedTrafficStats = () => {
const { t } = useTranslation() const { t } = useTranslation()
const theme = useTheme() const theme = useTheme()
@@ -302,11 +254,10 @@ export const EnhancedTrafficStats = () => {
}, [t, parsedData, displayMemory]) }, [t, parsedData, displayMemory])
return ( return (
<ErrorBoundary <TrafficErrorBoundary
FallbackComponent={TrafficFallback} onError={(error, errorInfo) => {
onError={(error, errorInfo) => console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
console.error('[EnhancedTrafficStats]', error, errorInfo) }}
}
> >
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}> <Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && ( {trafficGraph && (
@@ -322,6 +273,6 @@ export const EnhancedTrafficStats = () => {
</Grid> </Grid>
))} ))}
</Grid> </Grid>
</ErrorBoundary> </TrafficErrorBoundary>
) )
} }
+9 -37
View File
@@ -4,19 +4,10 @@ import {
VisibilityOffOutlined, VisibilityOffOutlined,
VisibilityOutlined, VisibilityOutlined,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
Box,
Button,
FormControlLabel,
IconButton,
Skeleton,
Switch,
Typography,
} from '@mui/material'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from 'foxact/use-abortable-effect' import { useEffect } from 'foxact/use-abortable-effect'
import { useIntersection } from 'foxact/use-intersection' import { useIntersection } from 'foxact/use-intersection'
import { useLocalStorage } from 'foxact/use-local-storage'
import type { XOR } from 'foxts/ts-xor' import type { XOR } from 'foxts/ts-xor'
import { import {
forwardRef, forwardRef,
@@ -108,13 +99,9 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
export const IpInfoCard = () => { export const IpInfoCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [showIp, setShowIp] = useState(false) const [showIp, setShowIp] = useState(false)
const [autoRefresh, setAutoRefresh] = useLocalStorage(
'ip-info-auto-refresh',
true,
)
const appWindow = useMemo(() => getCurrentWebviewWindow(), []) const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
// Once intersected, the card stays eligible for refresh until unmount. // Once intersected, refreshes stay enabled until unmount.
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({ const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: '0px', rootMargin: '0px',
}) })
@@ -164,8 +151,6 @@ export const IpInfoCard = () => {
}) })
useEffect(() => { useEffect(() => {
if (!autoRefresh) return
let timer: number | null = null let timer: number | null = null
// Prefer DOM intersection state; Tauri visibility is unreliable on some platforms. // Prefer DOM intersection state; Tauri visibility is unreliable on some platforms.
@@ -205,7 +190,7 @@ export const IpInfoCard = () => {
if (timer != null) clearInterval(timer) if (timer != null) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
} }
}, [autoRefresh, hasIntersected]) }, [hasIntersected])
const toggleShowIp = useCallback(() => { const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev) setShowIp((prev) => !prev)
@@ -379,25 +364,12 @@ export const IpInfoCard = () => {
fontSize: '0.7rem', fontSize: '0.7rem',
}} }}
> >
<FormControlLabel <Typography variant="caption">
sx={{ m: 0 }} {t('home.components.ipInfo.labels.autoRefresh')}
control={ {countdown.type === 'countdown'
<Switch ? `: ${countdown.remainingSeconds}s`
size="small" : '...'}
checked={autoRefresh} </Typography>
onChange={(_, checked) => setAutoRefresh(checked)}
/>
}
label={
<Typography variant="caption">
{t('home.components.ipInfo.labels.autoRefresh')}
{autoRefresh &&
(countdown.type === 'countdown'
? `: ${countdown.remainingSeconds}s`
: '...')}
</Typography>
}
/>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
+13 -6
View File
@@ -86,11 +86,16 @@ export const SystemInfoCard = () => {
}, [verge, patchVerge]) }, [verge, patchVerge])
const handleRunningModeClick = useCallback(async () => { const handleRunningModeClick = useCallback(async () => {
if (isSidecarMode) { if (isSidecarMode || (isAdminMode && isSidecarMode)) {
await installServiceAndRestartCore() await installServiceAndRestartCore()
await mutateSystemState() await mutateSystemState()
} }
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState]) }, [
isSidecarMode,
isAdminMode,
installServiceAndRestartCore,
mutateSystemState,
])
const onCheckUpdate = useLockFn(async () => { const onCheckUpdate = useLockFn(async () => {
try { try {
@@ -117,16 +122,18 @@ export const SystemInfoCard = () => {
const runningModeStyle = useMemo( const runningModeStyle = useMemo(
() => ({ () => ({
cursor: isSidecarMode ? 'pointer' : 'default', cursor:
textDecoration: isSidecarMode ? 'underline' : 'none', isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: 0.5,
'&:hover': { '&:hover': {
opacity: isSidecarMode ? 0.7 : 1, opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
}, },
}), }),
[isSidecarMode], [isSidecarMode, isAdminMode],
) )
const getModeIcon = () => { const getModeIcon = () => {
+3 -3
View File
@@ -87,7 +87,7 @@ export const TestCard = () => {
x.uid === uid ? { ...x, ...patch } : x, x.uid === uid ? { ...x, ...patch } : x,
) )
mutateVerge({ ...verge, test_list: newList }) mutateVerge({ ...verge, test_list: newList }, false)
}, },
[testList, verge, mutateVerge], [testList, verge, mutateVerge],
) )
@@ -96,7 +96,7 @@ export const TestCard = () => {
(uid: string) => { (uid: string) => {
const newList = testList.filter((x) => x.uid !== uid) const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList }) patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList }) mutateVerge({ ...verge, test_list: newList }, false)
}, },
[testList, verge, patchVerge, mutateVerge], [testList, verge, patchVerge, mutateVerge],
) )
@@ -129,7 +129,7 @@ export const TestCard = () => {
newList.splice(newIndex, 0, removed) newList.splice(newIndex, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死 // 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }) mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => { const patchFn = () => {
try { try {
patchVerge({ test_list: newList }) patchVerge({ test_list: newList })
+4 -1
View File
@@ -35,7 +35,10 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const handleMenuOrderOptimisticUpdate = useCallback( const handleMenuOrderOptimisticUpdate = useCallback(
(order: string[]) => { (order: string[]) => {
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev)) mutateVerge(
(prev) => (prev ? { ...prev, menu_order: order } : prev),
false,
)
}, },
[mutateVerge], [mutateVerge],
) )
+4 -33
View File
@@ -1,15 +1,14 @@
import { import {
ArrowDownwardRounded, ArrowDownwardRounded,
ArrowUpwardRounded, ArrowUpwardRounded,
ErrorOutlineRounded,
MemoryRounded, MemoryRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material' import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from '@/hooks/use-memory-data' import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data' import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
@@ -18,30 +17,6 @@ import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from './traffic-graph' import { TrafficGraph, type TrafficRef } from './traffic-graph'
const unavailableSx = {
p: 1,
minHeight: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}
const TrafficUnavailableFallback = () => {
const { t } = useTranslation()
return (
<Box sx={unavailableSx}>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
)
}
export const LayoutTraffic = () => { export const LayoutTraffic = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { verge } = useVerge() const { verge } = useVerge()
@@ -100,12 +75,7 @@ export const LayoutTraffic = () => {
} }
return ( return (
<ErrorBoundary <LightweightTrafficErrorBoundary>
FallbackComponent={TrafficUnavailableFallback}
onError={(error, errorInfo) =>
console.error('[LayoutTraffic]', error, errorInfo)
}
>
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative' }}>
{trafficGraph && pageVisible && ( {trafficGraph && pageVisible && (
<div <div
@@ -160,6 +130,7 @@ export const LayoutTraffic = () => {
cursor: 'auto', cursor: 'auto',
}} }}
color={'disabled'} color={'disabled'}
onClick={async () => {}}
> >
<MemoryRounded {...iconStyle} /> <MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography> <Typography {...valStyle}>{inuse}</Typography>
@@ -168,6 +139,6 @@ export const LayoutTraffic = () => {
)} )}
</Box> </Box>
</Box> </Box>
</ErrorBoundary> </LightweightTrafficErrorBoundary>
) )
} }
+36 -4
View File
@@ -1,6 +1,11 @@
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material' import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material' import { Box, IconButton } from '@mui/material'
import { type PointerEvent, useCallback } from 'react' import {
forwardRef,
type PointerEvent,
useCallback,
useImperativeHandle,
} from 'react'
import { useWindowControls } from '@/hooks/use-window' import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
@@ -58,9 +63,36 @@ export const WindowResizeHandles = () => {
) )
} }
export const WindowControls = () => { export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem() const OS = getSystem()
const { maximized, minimize, close, toggleMaximize } = useWindowControls() const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls()
useImperativeHandle(
ref,
() => ({
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
}),
[
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
],
)
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 // 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -154,4 +186,4 @@ export const WindowControls = () => {
)} )}
</Box> </Box>
) )
} })
@@ -397,9 +397,9 @@ export const RulesEditorViewer = (props: Props) => {
return return
} }
setPrependSeq((obj?.prepend || []).filter((rule) => rule !== null)) setPrependSeq(obj?.prepend || [])
setAppendSeq((obj?.append || []).filter((rule) => rule !== null)) setAppendSeq(obj?.append || [])
setDeleteSeq((obj?.delete || []).filter((rule) => rule !== null)) setDeleteSeq(obj?.delete || [])
hasLoadedSeqConfigRef.current = true hasLoadedSeqConfigRef.current = true
}, [property]) }, [property])
@@ -417,9 +417,9 @@ export const RulesEditorViewer = (props: Props) => {
hasLoadedSeqConfigRef.current = true hasLoadedSeqConfigRef.current = true
startTransition(() => { startTransition(() => {
setPrependSeq((obj?.prepend ?? []).filter((rule) => rule !== null)) setPrependSeq(obj?.prepend ?? [])
setAppendSeq((obj?.append ?? []).filter((rule) => rule !== null)) setAppendSeq(obj?.append ?? [])
setDeleteSeq((obj?.delete ?? []).filter((rule) => rule !== null)) setDeleteSeq(obj?.delete ?? [])
}) })
setVisualization(true) setVisualization(true)
} }
+4 -4
View File
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
} }
}, [handleScroll]) }, [handleScroll])
const { select } = useProxySelection({ const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => { onSuccess: () => {
onProxies() onProxies()
}, },
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return if (!isInteractableMember(member)) return
select(group.name, member.ref.name, group.now!, group.fixed!) handleProxyGroupChange(group, { name: member.ref.name })
}, },
[select], [handleProxyGroupChange],
) )
const handleLocation = useStableCallback((group: ProxyGroupView) => { const handleLocation = useStableCallback((group: ProxyGroupView) => {
@@ -608,7 +608,7 @@ function NormalProxyGroups(props: { mode: string }) {
/> />
</DragDropProvider> </DragDropProvider>
{(mode === 'rule' || mode === 'global') && ( {mode === 'rule' && (
<ProxyGroupNavigator <ProxyGroupNavigator
proxyGroupNames={proxyGroupNames} proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName} onGroupLocation={handleGroupLocationByName}
@@ -30,8 +30,10 @@ export const useProxyGroupHeaderLayout = () => {
if (canceled || !operation.source || !operation.target) return if (canceled || !operation.source || !operation.target) return
const position = toolsOnLeft ? 'right' : 'left' const position = toolsOnLeft ? 'right' : 'left'
mutateVerge((prev) => mutateVerge(
prev ? { ...prev, proxy_group_tools_position: position } : prev, (prev) =>
prev ? { ...prev, proxy_group_tools_position: position } : prev,
false,
) )
try { try {
await patchVerge({ proxy_group_tools_position: position }) await patchVerge({ proxy_group_tools_position: position })
+2 -2
View File
@@ -211,7 +211,7 @@ export const useRenderList = (
if (!proxyView) return [] if (!proxyView) return []
if (isChainMode) if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP] return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return mode === 'rule' || mode === 'global' return mode === 'rule' || mode === 'script'
? proxyView.groups.map(({ name }) => name) ? proxyView.groups.map(({ name }) => name)
: proxyView.global : proxyView.global
? [proxyView.global.name] ? [proxyView.global.name]
@@ -257,7 +257,7 @@ export const useRenderList = (
})) }))
} }
const useRule = mode === 'rule' || mode === 'global' const useRule = mode === 'rule' || mode === 'script'
const renderGroups = useRule const renderGroups = useRule
? proxyView.groups ? proxyView.groups
: proxyView.global === null : proxyView.global === null
@@ -40,9 +40,9 @@ export const BackupConfigViewer = memo(
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({ const { register, handleSubmit, watch } = useForm<IWebDavConfig>({
defaultValues: { defaultValues: {
url: webdav_url ?? '', url: webdav_url!,
username: webdav_username ?? '', username: webdav_username!,
password: webdav_password ?? '', password: webdav_password!,
}, },
}) })
const url = watch('url') const url = watch('url')
@@ -50,9 +50,9 @@ export const BackupConfigViewer = memo(
const password = watch('password') const password = watch('password')
const webdavChanged = const webdavChanged =
(webdav_url ?? '') !== url || webdav_url !== url ||
(webdav_username ?? '') !== username || webdav_username !== username ||
(webdav_password ?? '') !== password webdav_password !== password
const webdavSignature = buildWebdavSignature(verge) const webdavSignature = buildWebdavSignature(verge)
const webdavStatus = getWebdavStatus(webdavSignature) const webdavStatus = getWebdavStatus(webdavSignature)
@@ -69,9 +69,9 @@ export const BackupConfigViewer = memo(
}, [webdav_url, webdav_username, webdav_password, onInit, shouldAutoInit]) }, [webdav_url, webdav_username, webdav_password, onInit, shouldAutoInit])
const checkForm = () => { const checkForm = () => {
const username = usernameRef.current?.value.trim() const username = usernameRef.current?.value
const password = passwordRef.current?.value const password = passwordRef.current?.value
const url = urlRef.current?.value.trim() const url = urlRef.current?.value
if (!url) { if (!url) {
urlRef.current?.focus() urlRef.current?.focus()
@@ -95,15 +95,14 @@ export const BackupConfigViewer = memo(
} }
const save = useLockFn(async (data: IWebDavConfig) => { const save = useLockFn(async (data: IWebDavConfig) => {
const trimmedUrl = data.url.trim() checkForm()
const trimmedUsername = data.username.trim()
const isClearing = !trimmedUrl && !trimmedUsername && !data.password
if (!isClearing) checkForm()
const signature = buildWebdavSignature({ const signature = buildWebdavSignature({
webdav_url: data.url, webdav_url: data.url,
webdav_username: data.username, webdav_username: data.username,
webdav_password: data.password, webdav_password: data.password,
}) })
const trimmedUrl = data.url.trim()
const trimmedUsername = data.username.trim()
try { try {
setLoading(true) setLoading(true)
@@ -114,19 +113,21 @@ export const BackupConfigViewer = memo(
errorNotice: false, errorNotice: false,
}, },
) )
await mutateVerge((current) => await mutateVerge(
current (current) =>
? { current
...current, ? {
webdav_url: trimmedUrl, ...current,
webdav_username: trimmedUsername, webdav_url: trimmedUrl,
webdav_password: data.password, webdav_username: trimmedUsername,
} webdav_password: data.password,
: current, }
: current,
false,
) )
setWebdavStatus(signature, 'unknown') setWebdavStatus(signature, 'unknown')
showNotice.success('settings.modals.backup.messages.webdavConfigSaved') showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
if (!isClearing) await onSaveSuccess(signature) await onSaveSuccess(signature)
} catch (error) { } catch (error) {
showNotice.error( showNotice.error(
'settings.modals.backup.messages.webdavConfigSaveFailed', 'settings.modals.backup.messages.webdavConfigSaveFailed',
@@ -222,9 +223,9 @@ export const BackupConfigViewer = memo(
}} }}
> >
{webdavChanged || {webdavChanged ||
!webdav_url || webdav_url === undefined ||
!webdav_username || webdav_username === undefined ||
!webdav_password ? ( webdav_password === undefined ? (
<Button <Button
variant="contained" variant="contained"
color={'primary'} color={'primary'}
@@ -1,43 +0,0 @@
import { Button, ButtonGroup } from '@mui/material'
interface Props {
value?: string
onChange?: (value: string) => void
}
export const DnsModeSwitch = (props: Props) => {
const { value, onChange } = props
return (
<ButtonGroup size="small" sx={{ my: '4px' }}>
<Button
variant={!value ? 'contained' : 'outlined'}
onClick={() => onChange?.('')}
sx={{ textTransform: 'capitalize' }}
>
Auto
</Button>
<Button
variant={value === 'disabled' ? 'contained' : 'outlined'}
onClick={() => onChange?.('disabled')}
sx={{ textTransform: 'capitalize' }}
>
Disabled
</Button>
<Button
variant={value === 'native' ? 'contained' : 'outlined'}
onClick={() => onChange?.('native')}
sx={{ textTransform: 'capitalize' }}
>
Native
</Button>
<Button
variant={value === 'hijack' ? 'contained' : 'outlined'}
onClick={() => onChange?.('hijack')}
sx={{ textTransform: 'capitalize' }}
>
Hijack
</Button>
</ButtonGroup>
)
}
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
showNotice.error(err) showNotice.error(err)
} }
const onChangeData = (patch: Partial<IVergeConfig>) => { const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }) mutateVerge({ ...verge, ...patch }, false)
} }
return ( return (
+29 -18
View File
@@ -15,33 +15,44 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
const buildInitialValues = (verge?: IVergeConfig | null) => ({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
export const MiscViewer = forwardRef<DialogRef>((props, ref) => { export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { verge, patchVerge } = useVerge() const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [values, setValues] = useState(() => buildInitialValues(null)) const [values, setValues] = useState({
appLogLevel: 'warn',
appLogMaxSize: 8,
appLogMaxCount: 12,
autoCloseConnection: true,
autoCheckUpdate: true,
enableBuiltinEnhanced: true,
proxyLayoutColumn: 6,
enableAutoDelayDetection: false,
autoDelayDetectionIntervalMinutes: 5,
defaultLatencyTest: '',
autoLogClean: 2,
defaultLatencyTimeout: 10000,
})
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setValues(buildInitialValues(verge)) setValues({
appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8,
autoCloseConnection: verge?.auto_close_connection ?? true,
autoCheckUpdate: verge?.auto_check_update ?? true,
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
proxyLayoutColumn: verge?.proxy_layout_column || 6,
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
autoDelayDetectionIntervalMinutes:
verge?.auto_delay_detection_interval_minutes ?? 5,
defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
})
}, },
close: () => setOpen(false), close: () => setOpen(false),
})) }))
+21 -14
View File
@@ -87,17 +87,6 @@ const splitBypass = (value?: string) =>
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
const buildInitialValues = (verge?: IVergeConfig | null) => ({
guard: verge?.enable_proxy_guard,
enable_bypass_check: verge?.enable_bypass_check ?? true,
bypass: verge?.system_proxy_bypass,
duration: verge?.proxy_guard_duration ?? 10,
use_default: verge?.use_default_bypass ?? true,
pac: verge?.proxy_auto_config,
pac_content: verge?.pac_file_content ?? DEFAULT_PAC,
proxy_host: verge?.proxy_host ?? '127.0.0.1',
})
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => { export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const systemName = getSystem() const systemName = getSystem()
@@ -127,7 +116,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
proxy_host, proxy_host,
} = verge ?? {} } = verge ?? {}
const [value, setValue] = useState(() => buildInitialValues(null)) const [value, setValue] = useState({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>( const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
null, null,
) )
@@ -194,7 +192,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setValue(buildInitialValues(verge)) setValue({
guard: enable_proxy_guard,
enable_bypass_check: enable_bypass_check ?? true,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? '127.0.0.1',
})
fetchNetworkInterfaces() fetchNetworkInterfaces()
}, },
close: () => setOpen(false), close: () => setOpen(false),
@@ -332,7 +339,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
Promise.resolve().then(async () => { Promise.resolve().then(async () => {
try { try {
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }) mutateVerge({ ...verge, ...patch }, false)
} }
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await patchVerge(patch) await patchVerge(patch)
@@ -26,7 +26,6 @@ import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { areValidIpCidrs } from '@/utils/network' import { areValidIpCidrs } from '@/utils/network'
import { DnsModeSwitch } from './dns-mode-switch'
import { StackModeSwitch } from './stack-mode-switch' import { StackModeSwitch } from './stack-mode-switch'
const OS = getSystem() const OS = getSystem()
@@ -53,7 +52,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: ['any:53'], dnsHijack: ['any:53'],
strictRoute: false, strictRoute: false,
mtu: 1500, mtu: 1500,
dnsMode: '',
}) })
const routeExcludeAddressItems = splitRouteExcludeAddress( const routeExcludeAddressItems = splitRouteExcludeAddress(
@@ -86,7 +84,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: clash?.tun['dns-hijack'] ?? ['any:53'], dnsHijack: clash?.tun['dns-hijack'] ?? ['any:53'],
strictRoute: clash?.tun['strict-route'] ?? false, strictRoute: clash?.tun['strict-route'] ?? false,
mtu: clash?.tun.mtu ?? 1500, mtu: clash?.tun.mtu ?? 1500,
dnsMode: clash?.tun['dns-mode'] ?? '',
}) })
}, },
close: () => setOpen(false), close: () => setOpen(false),
@@ -120,7 +117,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
: {}), : {}),
'auto-detect-interface': values.autoDetectInterface, 'auto-detect-interface': values.autoDetectInterface,
'dns-hijack': values.dnsHijack[0] === '' ? [] : values.dnsHijack, 'dns-hijack': values.dnsHijack[0] === '' ? [] : values.dnsHijack,
'dns-mode': values.dnsMode,
'strict-route': values.strictRoute, 'strict-route': values.strictRoute,
mtu: values.mtu ?? 1500, mtu: values.mtu ?? 1500,
} }
@@ -167,7 +163,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
'auto-detect-interface': true, 'auto-detect-interface': true,
'dns-hijack': ['any:53'], 'dns-hijack': ['any:53'],
'route-exclude-address': [], 'route-exclude-address': [],
'dns-mode': '',
'strict-route': false, 'strict-route': false,
mtu: 1500, mtu: 1500,
} }
@@ -181,7 +176,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: ['any:53'], dnsHijack: ['any:53'],
strictRoute: false, strictRoute: false,
mtu: 1500, mtu: 1500,
dnsMode: '',
}) })
await patchClash({ tun }) await patchClash({ tun })
await mutateClash( await mutateClash(
@@ -218,25 +212,6 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.dnsMode')}
sx={{ maxWidth: 'fit-content' }}
/>
<TooltipIcon
title={t('settings.modals.tun.tooltips.dnsMode')}
sx={{ opacity: 0.7 }}
/>
<Box sx={{ marginLeft: 'auto' }}>
<DnsModeSwitch
value={values.dnsMode}
onChange={(value) => {
setValues((v) => ({ ...v, dnsMode: value }))
}}
/>
</Box>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.device')} /> <ListItemText primary={t('settings.modals.tun.fields.device')} />
<TextField <TextField
+18 -12
View File
@@ -76,28 +76,34 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
member.kind === 'node' ? member.node.recordId : member.ref.name member.kind === 'node' ? member.node.recordId : member.ref.name
}` }`
const buildInitialValues = (): TunnelFormValues => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => { export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { clash, mutateClash, patchClash } = useClash() const { clash, mutateClash, patchClash } = useClash()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues) const [values, setValues] = useState<TunnelFormValues>({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
})
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([]) const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setValues(buildInitialValues()) setValues(() => ({
localAddr: '',
localPort: '',
targetAddr: '',
targetPort: '',
network: 'tcp+udp',
group: '',
proxy: null,
}))
setDraftTunnels(() => clash?.tunnels ?? []) setDraftTunnels(() => clash?.tunnels ?? [])
setOpen(true) setOpen(true)
// 如果没有隧道,则自动展开 // 如果没有隧道,则自动展开
@@ -72,10 +72,12 @@ const LazyReactMarkdown = lazy(async () => {
{ default: ReactMarkdown }, { default: ReactMarkdown },
{ default: rehypeRaw }, { default: rehypeRaw },
{ default: rehypeSanitize, defaultSchema }, { default: rehypeSanitize, defaultSchema },
{ default: remarkGfm },
] = await Promise.all([ ] = await Promise.all([
import('react-markdown'), import('react-markdown'),
import('rehype-raw'), import('rehype-raw'),
import('rehype-sanitize'), import('rehype-sanitize'),
import('remark-gfm'),
]) ])
const sanitizeSchema = { const sanitizeSchema = {
@@ -102,6 +104,7 @@ const LazyReactMarkdown = lazy(async () => {
default: (props: ReactMarkdownOptions) => ( default: (props: ReactMarkdownOptions) => (
<ReactMarkdown <ReactMarkdown
{...props} {...props}
remarkPlugins={[remarkGfm, ...(props.remarkPlugins ?? [])]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
/> />
), ),
@@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const handleAdd = useLockFn(async (value: string) => { const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value] const newList = [...webUIList, value]
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList }) await patchVerge({ web_ui_list: newList })
}) })
const handleChange = useLockFn(async (index: number, value?: string) => { const handleChange = useLockFn(async (index: number, value?: string) => {
const newList = [...webUIList] const newList = [...webUIList]
newList[index] = value ?? '' newList[index] = value ?? ''
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList }) await patchVerge({ web_ui_list: newList })
}) })
const handleDelete = useLockFn(async (index: number) => { const handleDelete = useLockFn(async (index: number) => {
const newList = [...webUIList] const newList = [...webUIList]
newList.splice(index, 1) newList.splice(index, 1)
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
await patchVerge({ web_ui_list: newList }) await patchVerge({ web_ui_list: newList })
}) })
+13 -3
View File
@@ -1,12 +1,14 @@
import React from 'react' import React, { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Switch, TooltipIcon } from '@/components/base' import { DialogRef, Switch, TooltipIcon } from '@/components/base'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches' import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { GuardState } from './mods/guard-state' import { GuardState } from './mods/guard-state'
import { SettingList, SettingItem } from './mods/setting-comp' import { SettingList, SettingItem } from './mods/setting-comp'
import { SysproxyViewer } from './mods/sysproxy-viewer'
import { TunViewer } from './mods/tun-viewer'
interface Props { interface Props {
onError?: (err: Error) => void onError?: (err: Error) => void
@@ -19,16 +21,22 @@ const SettingSystem = ({ onError }: Props) => {
const { enable_auto_launch, enable_silent_start } = verge ?? {} const { enable_auto_launch, enable_silent_start } = verge ?? {}
const sysproxyRef = useRef<DialogRef>(null)
const tunRef = useRef<DialogRef>(null)
const onSwitchFormat = ( const onSwitchFormat = (
_e: React.ChangeEvent<HTMLInputElement>, _e: React.ChangeEvent<HTMLInputElement>,
value: boolean, value: boolean,
) => value ) => value
const onChangeData = (patch: Partial<IVergeConfig>) => { const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }) mutateVerge({ ...verge, ...patch }, false)
} }
return ( return (
<SettingList title={t('settings.sections.system.title')}> <SettingList title={t('settings.sections.system.title')}>
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<ProxyControlSwitches <ProxyControlSwitches
label={t('settings.sections.system.toggles.tunMode')} label={t('settings.sections.system.toggles.tunMode')}
onError={onError} onError={onError}
@@ -50,6 +58,8 @@ const SettingSystem = ({ onError }: Props) => {
}} }}
onGuard={async (e) => { onGuard={async (e) => {
try { try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e })
await patchVerge({ enable_auto_launch: e }) await patchVerge({ enable_auto_launch: e })
return Promise.resolve() return Promise.resolve()
} catch (error) { } catch (error) {
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
const backupRef = useRef<DialogRef>(null) const backupRef = useRef<DialogRef>(null)
const onChangeData = (patch: any) => { const onChangeData = (patch: any) => {
mutateVerge({ ...verge, ...patch }) mutateVerge({ ...verge, ...patch }, false)
} }
const onCopyClashEnv = useCallback(async () => { const onCopyClashEnv = useCallback(async () => {
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
}) })
return false return false
} }
mutateVerge({ ...verge, enable_tun_mode: value }) mutateVerge({ ...verge, enable_tun_mode: value }, false)
await patchVerge({ enable_tun_mode: value }) await patchVerge({ enable_tun_mode: value })
} }
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
/> />
)} )}
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />} <SysproxyViewer ref={sysproxyRef} />
{isTunMode && <TunViewer ref={tunRef} />} <TunViewer ref={tunRef} />
</Box> </Box>
) )
} }
@@ -0,0 +1,301 @@
import {
ErrorOutlineRounded,
RefreshRounded,
BugReportRounded,
} from '@mui/icons-material'
import { Box, Typography, Button, Alert, Collapse } from '@mui/material'
import React, { Component, ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
children: ReactNode
fallbackComponent?: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
}
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0
private maxRetries = 3
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo)
this.setState({
error,
errorInfo,
})
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
this.reportError(error, errorInfo)
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
const errorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
}
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
}
private handleRetry = () => {
if (this.retryCount < this.maxRetries) {
this.retryCount++
console.log(
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
)
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
})
} else {
console.warn('[TrafficErrorBoundary] 已达到最大重试次数')
}
}
private handleRefresh = () => {
window.location.reload()
}
private toggleDetails = () => {
this.setState((prev) => ({ showDetails: !prev.showDetails }))
}
render() {
if (this.state.hasError) {
if (this.props.fallbackComponent) {
return this.props.fallbackComponent
}
return (
<TrafficErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
showDetails={this.state.showDetails}
canRetry={this.retryCount < this.maxRetries}
retryCount={this.retryCount}
maxRetries={this.maxRetries}
onRetry={this.handleRetry}
onRefresh={this.handleRefresh}
onToggleDetails={this.toggleDetails}
/>
)
}
return this.props.children
}
}
interface TrafficErrorFallbackProps {
error: Error | null
errorInfo: ErrorInfo | null
showDetails: boolean
canRetry: boolean
retryCount: number
maxRetries: number
onRetry: () => void
onRefresh: () => void
onToggleDetails: () => void
}
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
error,
errorInfo,
showDetails,
canRetry,
retryCount,
maxRetries,
onRetry,
onRefresh,
onToggleDetails,
}) => {
const { t } = useTranslation()
return (
<Box
sx={{
p: 2,
minHeight: 200,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed',
borderColor: 'error.main',
borderRadius: 2,
bgcolor: 'error.light',
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: 'error.main' }} />
<Typography variant="h6" gutterBottom>
{t('shared.feedback.errors.trafficStats')}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, textAlign: 'center' }}
>
{t('shared.feedback.errors.trafficStatsDescription')}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="body2">
<strong>{t('shared.feedback.errors.label')}:</strong>{' '}
{error instanceof Error
? error.message
: t('shared.feedback.errors.unknown')}
</Typography>
{retryCount > 0 && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
{t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries}
</Typography>
)}
</Alert>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
{canRetry && (
<Button
variant="contained"
color="primary"
startIcon={<RefreshRounded />}
onClick={onRetry}
size="small"
>
{t('shared.actions.retry')}
</Button>
)}
<Button variant="outlined" onClick={onRefresh} size="small">
{t('shared.actions.refreshPage')}
</Button>
<Button
variant="text"
startIcon={<BugReportRounded />}
onClick={onToggleDetails}
size="small"
>
{showDetails
? t('shared.actions.hideDetails')
: t('shared.actions.showDetails')}
</Button>
</Box>
<Collapse in={showDetails} sx={{ width: '100%', maxWidth: 600 }}>
<Box
sx={{
p: 2,
bgcolor: 'background.paper',
borderRadius: 1,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{t('shared.feedback.errors.details')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{error?.stack}
</Typography>
{errorInfo?.componentStack && (
<>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
{t('shared.feedback.errors.componentStack')}:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
>
{errorInfo.componentStack}
</Typography>
</>
)}
</Box>
</Collapse>
</Box>
)
}
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode
}> = ({ children }) => {
const { t } = useTranslation()
return (
<TrafficErrorBoundary
fallbackComponent={
<Box
sx={{
p: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 60,
bgcolor: 'error.light',
borderRadius: 1,
color: 'error.contrastText',
}}
>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">
{t('shared.feedback.errors.trafficUnavailable')}
</Typography>
</Box>
}
>
{children}
</TrafficErrorBoundary>
)
}
+4 -2
View File
@@ -1,4 +1,5 @@
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { useMemo } from 'react'
import { downloadIconCache } from '@/services/cmds' import { downloadIconCache } from '@/services/cmds'
import { useQuery } from '@/services/query-client' import { useQuery } from '@/services/query-client'
@@ -22,8 +23,9 @@ export const useIconCache = ({
const iconValue = icon?.trim() ?? '' const iconValue = icon?.trim() ?? ''
const cacheKeyValue = cacheKey?.trim() ?? '' const cacheKeyValue = cacheKey?.trim() ?? ''
const isEnabled = const isEnabled = useMemo(() => {
enabled && iconValue.startsWith('http') && cacheKeyValue !== '' return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
}, [enabled, iconValue, cacheKeyValue])
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['icon-cache', iconValue, cacheKeyValue], queryKey: ['icon-cache', iconValue, cacheKeyValue],
+8 -4
View File
@@ -54,7 +54,8 @@ export const useLogData = () => {
const hasLoadedInitialLogsRef = useRef(false) const hasLoadedInitialLogsRef = useRef(false)
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({ const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
subscriptionKey: enableLog ? 'getClashLog' : null, storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
fallbackData: [], fallbackData: [],
connect: () => MihomoWebSocket.connect_logs(logLevel), connect: () => MihomoWebSocket.connect_logs(logLevel),
setupHandlers: ({ next, scheduleReconnect, isMounted }) => { setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
@@ -76,13 +77,14 @@ export const useLogData = () => {
} }
const pendingLogs = buffer.splice(0, buffer.length) const pendingLogs = buffer.splice(0, buffer.length)
flushTimeStr = null flushTimeStr = null
next((current) => appendLogs(current, pendingLogs)) next(null, (current) => appendLogs(current, pendingLogs))
flushTimer = null flushTimer = null
} }
return { return {
handleMessage: (data) => { handleMessage: (data) => {
if (data.startsWith('Websocket error')) { if (data.startsWith('Websocket error')) {
next(data)
void scheduleReconnect() void scheduleReconnect()
return return
} }
@@ -106,7 +108,9 @@ export const useLogData = () => {
if (!flushTimer) { if (!flushTimer) {
flushTimer = setTimeout(flush, FLUSH_DELAY_MS) flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
} }
} catch {} } catch (error) {
next(error)
}
}, },
async onConnected() { async onConnected() {
if (hasLoadedInitialLogsRef.current) { if (hasLoadedInitialLogsRef.current) {
@@ -115,7 +119,7 @@ export const useLogData = () => {
const logs = await getClashLogs() const logs = await getClashLogs()
hasLoadedInitialLogsRef.current = true hasLoadedInitialLogsRef.current = true
if (isMounted()) { if (isMounted()) {
next((current) => { next(null, (current) => {
if (!current || current.length === 0) { if (!current || current.length === 0) {
return clampLogs(filterLogsByLevel(logs, allowedTypes)) return clampLogs(filterLogsByLevel(logs, allowedTypes))
} }
+7 -3
View File
@@ -33,13 +33,15 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
const enabled = options?.enabled ?? true const enabled = options?.enabled ?? true
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({ const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
subscriptionKey: enabled ? 'getClashMemory' : null, storageKey: 'mihomo_memory_date',
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
fallbackData: FALLBACK_MEMORY_USAGE, fallbackData: FALLBACK_MEMORY_USAGE,
connect: () => MihomoWebSocket.connect_memory(), connect: () => MihomoWebSocket.connect_memory(),
throttleMs: 500, throttleMs: 500,
setupHandlers: ({ next, scheduleReconnect }) => ({ setupHandlers: ({ next, scheduleReconnect }) => ({
handleMessage: (data) => { handleMessage: (data) => {
if (data.startsWith('Websocket error')) { if (data.startsWith('Websocket error')) {
next(data, FALLBACK_MEMORY_USAGE)
void scheduleReconnect() void scheduleReconnect()
return return
} }
@@ -49,8 +51,10 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
if (shouldSkipDuplicateMemory(parsed)) { if (shouldSkipDuplicateMemory(parsed)) {
return return
} }
next(parsed) next(null, parsed)
} catch {} } catch (error) {
next(error, FALLBACK_MEMORY_USAGE)
}
}, },
}), }),
}) })

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