Compare commits

..
1 Commits
Author SHA1 Message Date
Tunglies 71429f9a42 feat!: migrate to tauri 3.0.0-alpha
Move the app onto the tauri v3 alpha line (tauri 3.0.0-alpha.4 with
the matching plugin alphas):

- the runtime is injected explicitly (tauri-runtime-wry +
  .runtime(Wry::default())) and tauri::Wry generics give way to the
  DynRuntime defaults (menu/tray types spell it out); the devtools
  feature moved onto the runtime crate; run_on_main_thread arrives
  via tauri::Manager; TrayIcon access uses with_inner_blocking.
- tauri-specta rides the tauri team's tauri-v3 port until it
  publishes, and that port pins the unpublished specta rc.26 line, so
  both resolve through [patch.crates-io] git entries - drop them once
  specta rc.26 and tauri-specta ship on crates.io. dev's specta IPC
  bindings, the frontend-contract export and its CI/pre-push parity
  checks keep working on top; bindings.ts regenerates with rc.26's
  named inline types (IVergeTestItem gains a _Serialize write shape,
  test-viewer normalizes new items to it).
- the tray-icon patch stays dropped (upstream 0.25.1 ships the macOS
  27 click-reentrancy fix); dev's muda patch is kept since tauri
  alpha.4 still rides muda 0.20.
- tauri-plugin-mihomo points at the plugin's migration branch
  (clash-verge-rev/tauri-plugin-mihomo#13) until it merges.
- the perf-harness feature and its main.rs wiring stay parked until
  tauri-plugin-wdio-webdriver publishes a tauri 3 release (1.5.0
  still requires tauri ^2.10); scripts/perf/app.rs stays for the
  restore.
- plugin-updater's JS API adapts to check() returning null instead of
  an Update with .available and the removed allowDowngrades option
  (the version comparison in checkUpdateSafe already guards
  downgrades); autostart renames ManagerExt::autolaunch to autostart.

Manifests and lockfiles on both sides: pnpm 12.11.2, @tauri-apps
api/cli alphas, minimumReleaseAgeExclude entries for the alpha.4 CLI
binaries.
2026-10-12 19:04:38 +08:00
109 changed files with 2888 additions and 1918 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.22.1", "version": "24.21.0",
"pnpmVersion": "12.14.0" "pnpmVersion": "12.11.2"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1", "version": "1.99.0",
"profile": "default" "profile": "default"
}, },
"ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/git: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
+10 -10
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: '24.22.1' node-version: '24.21.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -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: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -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: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -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: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -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: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.0
name: Install pnpm name: Install pnpm
+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: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.1 uses: dtolnay/rust-toolchain@1.99.0
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -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: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
run: | run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Restore pnpm cache - name: Restore pnpm cache
@@ -138,7 +138,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true' if: steps.check_inputs.outputs.contract == 'true'
with: with:
node-version: '24.22.1' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -230,7 +230,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -466,7 +466,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -547,7 +547,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -573,7 +573,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -638,7 +638,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: x86_64-pc-windows-msvc targets: x86_64-pc-windows-msvc
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '24.21.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: '24.22.1' node-version: '24.21.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: '24.22.1' node-version: '24.21.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: '24.22.1' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.22.1 nodejs 24.21.0
pnpm 12.14.0 pnpm 12.11.2
rust 1.99.1 rust 1.99.0
+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.
Generated
+342 -472
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -49,7 +49,15 @@ split-debuginfo = "unpacked"
[patch.crates-io] [patch.crates-io]
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch. # Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" } muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
# tauri-specta has no tauri 3 release yet; ride the tauri team's port and the
# unpublished specta rc.26 line it pins. Drop all six entries once both ship
# on crates.io.
tauri-specta = { git = "https://github.com/tauri-apps/tauri-specta", branch = "tauri-v3" }
specta = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-macros = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-serde = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-typescript = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-util = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
[workspace.dependencies] [workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" } clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -61,13 +69,14 @@ 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 = "3.0.0-alpha.4" }
tauri-plugin-clipboard-manager = "2.5.0" tauri-runtime-wry = "3.0.0-alpha.4"
tauri-plugin-clipboard-manager = "3.0.0-alpha.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] } criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.3", features = [ tokio = { version = "1.53.2", features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"time", "time",
@@ -88,7 +97,7 @@ rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" } smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_json = { version = "1.0.154" } serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" } bitflags = { version = "2.13.2" }
socket2 = "0.6.5" socket2 = "0.6.5"
+1 -9
View File
@@ -3,14 +3,7 @@
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details> </details>
@@ -23,7 +16,6 @@
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows** **🖥️ Windows**
+2
View File
@@ -70,6 +70,8 @@ tray:
global: عام global: عام
profiles: الملفات التعريفية profiles: الملفات التعريفية
proxies: وكلاء proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام systemProxy: وكيل النظام
tunMode: وضع TUN tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات closeAllConnections: إغلاق كل الاتصالات
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profile profiles: Profile
proxies: Proxy proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy systemProxy: Systemproxy
tunMode: TUN-Modus tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen closeAllConnections: Alle Verbindungen schließen
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profiles profiles: Profiles
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy systemProxy: System Proxy
tunMode: TUN Mode tunMode: TUN Mode
closeAllConnections: Close All Connections closeAllConnections: Close All Connections
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Perfiles profiles: Perfiles
proxies: Proxies proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema systemProxy: Proxy del sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones closeAllConnections: Cerrar todas las conexiones
+2
View File
@@ -70,6 +70,8 @@ tray:
global: سراسری global: سراسری
profiles: پروفایل‌ها profiles: پروفایل‌ها
proxies: پروکسی‌ها proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم systemProxy: پروکسی سیستم
tunMode: حالت TUN tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها closeAllConnections: بستن همه اتصال‌ها
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profil profiles: Profil
proxies: Proksi proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem systemProxy: Proksi Sistem
tunMode: Mode TUN tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi closeAllConnections: Tutup Semua Koneksi
+2
View File
@@ -70,6 +70,8 @@ tray:
global: グローバル global: グローバル
profiles: プロファイル profiles: プロファイル
proxies: プロキシ proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ systemProxy: システムプロキシ
tunMode: TUN モード tunMode: TUN モード
closeAllConnections: すべての接続を閉じる closeAllConnections: すべての接続を閉じる
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 글로벌 global: 글로벌
profiles: 프로필 profiles: 프로필
proxies: 프록시 proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시 systemProxy: 시스템 프록시
tunMode: TUN 모드 tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기 closeAllConnections: 모든 연결 닫기
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Perfis profiles: Perfis
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema systemProxy: Proxy do sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões closeAllConnections: Fechar todas as conexões
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобальный global: Глобальный
profiles: Профили profiles: Профили
proxies: Прокси proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси systemProxy: Системный прокси
tunMode: Режим TUN tunMode: Режим TUN
closeAllConnections: Закрыть все соединения closeAllConnections: Закрыть все соединения
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Küresel global: Küresel
profiles: Profiller profiles: Profiller
proxies: Vekil'ler proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i systemProxy: Sistem Vekil'i
tunMode: TUN Modu tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat closeAllConnections: Tüm Bağlantıları Kapat
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобаль global: Глобаль
profiles: Профильләр profiles: Профильләр
proxies: Проксилар proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси systemProxy: Системалы прокси
tunMode: TUN режимы tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу closeAllConnections: Барлык тоташуларны ябу
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全局 global: 全局
profiles: 订阅 profiles: 订阅
proxies: 代理 proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理 systemProxy: 系统代理
tunMode: TUN 模式 tunMode: TUN 模式
closeAllConnections: 关闭所有连接 closeAllConnections: 关闭所有连接
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全域 global: 全域
profiles: 訂閱 profiles: 訂閱
proxies: 代理 proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理 systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式 tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線 closeAllConnections: 關閉所有連線
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true } log = { workspace = true }
+31 -23
View File
@@ -1,15 +1,3 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{ use std::sync::{
OnceLock, OnceLock,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -17,13 +5,20 @@ use std::sync::{
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix; mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new(); pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
/// Serializes signal-triggered cleanups: a signal arriving while a quit #[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// attempt is still in flight is dropped instead of overlapping it. pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch { pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool, is_cleaning_up: AtomicBool,
} }
@@ -41,18 +36,17 @@ impl ShutdownLatch {
.is_ok() .is_ok()
} }
fn finish(&self) { fn finish(&self, outcome: ShutdownOutcome) {
self.is_cleaning_up.store(false, Ordering::Release); if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
} }
} }
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static, Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{ {
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt), Ok(rt) => Some(rt),
@@ -67,22 +61,36 @@ where
} }
}); });
#[cfg(unix)]
unix::register(f); unix::register(f);
#[cfg(windows)]
windows::register(f);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ShutdownLatch; use super::{ShutdownLatch, ShutdownOutcome};
#[test] #[test]
fn finished_attempt_releases_latch_for_the_next_signal() { fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new(); let latch = ShutdownLatch::new();
assert!(latch.try_begin()); assert!(latch.try_begin());
assert!(!latch.try_begin()); assert!(!latch.try_begin());
latch.finish(); latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin()); assert!(latch.try_begin());
} }
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
} }
+3 -4
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH}; use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static, Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{ {
if let Some(Some(rt)) = RUNTIME.get() { if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move { rt.spawn(async move {
@@ -60,8 +60,7 @@ where
} }
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await; SHUTDOWN_LATCH.finish(f().await);
SHUTDOWN_LATCH.finish();
} }
}); });
} else { } else {
+83
View File
@@ -0,0 +1,83 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -5,7 +5,7 @@ edition = "2024"
rust-version = "1.91" rust-version = "1.91"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
@@ -15,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] } sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
libc = "0.2.191" libc = "0.2.190"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
+15 -15
View File
@@ -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": "3.0.0-alpha.2",
"@tauri-apps/plugin-clipboard-manager": "2.5.0", "@tauri-apps/plugin-clipboard-manager": "^3.0.0-alpha.2",
"@tauri-apps/plugin-dialog": "2.8.3", "@tauri-apps/plugin-dialog": "^3.0.0-alpha.3",
"@tauri-apps/plugin-fs": "2.7.1", "@tauri-apps/plugin-fs": "^3.0.0-alpha.2",
"@tauri-apps/plugin-http": "2.8.3", "@tauri-apps/plugin-http": "~3.0.0-alpha.2",
"@tauri-apps/plugin-opener": "2.7.2", "@tauri-apps/plugin-opener": "3.0.0-alpha.2",
"@tauri-apps/plugin-updater": "2.14.0", "@tauri-apps/plugin-updater": "3.0.0-alpha.2",
"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",
@@ -75,11 +75,11 @@
"monaco-yaml": "^5.5.1", "monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1", "react-dom": "19.3.0",
"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.0.19", "react-i18next": "17.0.15",
"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",
@@ -95,7 +95,7 @@
"@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": "3.0.0-alpha.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6", "@types/node": "^24.13.6",
"@types/react": "19.3.0", "@types/react": "19.3.0",
@@ -103,7 +103,7 @@
"@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",
@@ -123,7 +123,7 @@
"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.41.0", "knip": "6.39.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",
@@ -133,7 +133,7 @@
"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.0.2", "webdriverio": "10.0.0",
"ws": "8.22.0" "ws": "8.22.0"
}, },
"lint-staged": { "lint-staged": {
@@ -146,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.14.0" "packageManager": "pnpm@12.11.2"
} }
+452 -451
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -10,3 +10,15 @@ allowBuilds:
unrs-resolver: true unrs-resolver: true
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- dayjs@1.11.21 - dayjs@1.11.21
- '@tauri-apps/cli-darwin-arm64@3.0.0-alpha.4'
- '@tauri-apps/cli-darwin-x64@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm-gnueabihf@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm64-musl@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-riscv64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-x64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-x64-musl@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-arm64-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-ia32-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-x64-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli@3.0.0-alpha.4'
-11
View File
@@ -45,17 +45,6 @@
"matchManagers": ["npm"], "matchManagers": ["npm"],
"groupName": "npm dependencies" "groupName": "npm dependencies"
}, },
{
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
"matchPackageNames": [
"@tauri-apps/**",
"@wdio/tauri-service",
"tauri",
"tauri-*"
],
"groupName": "tauri",
"addLabels": ["tauri"]
},
{ {
"description": "Group all GitHub Actions updates into a single PR", "description": "Group all GitHub Actions updates into a single PR",
"matchManagers": ["github-actions"], "matchManagers": ["github-actions"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.99.1" channel = "1.99.0"
components = ["rustfmt", "clippy"] components = ["rustfmt", "clippy"]
+1 -1
View File
@@ -22,7 +22,7 @@ try {
'run', 'run',
'-p', '-p',
'clash-verge', 'clash-verge',
'--example', '--bin',
'frontend-contract', 'frontend-contract',
'--locked', '--locked',
'-q', '-q',
+27 -29
View File
@@ -23,30 +23,33 @@ tauri-dev = ["dep:tauri-plugin-devtools"]
tokio-trace = ["clash-verge-logging/tokio-trace"] tokio-trace = ["clash-verge-logging/tokio-trace"]
chrome-trace = ["clash-verge-logging/chrome-trace"] chrome-trace = ["clash-verge-logging/chrome-trace"]
clippy = ["tauri/test"] clippy = ["tauri/test"]
perf-harness = ["dep:tauri-plugin-wdio-webdriver"] # The perf-harness feature and its main.rs wiring are parked until
# tauri-plugin-wdio-webdriver publishes a tauri 3 release; restore both
# from git history together with the crate dependency.
[package.metadata.bundle] [package.metadata.bundle]
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 = "3.0.0-alpha.3", features = [] }
[dependencies] [dependencies]
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true } clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true } clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true } clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [ tauri = { workspace = true, features = [
"protocol-asset", "protocol-asset",
"devtools",
"tracing", "tracing",
"tray-icon", "tray-icon",
"image-ico", "image-ico",
"image-png", "image-png",
] } ] }
tauri-runtime-wry = { workspace = true, features = ["devtools"] }
parking_lot = { workspace = true } parking_lot = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -72,55 +75,50 @@ 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 = "3.0.0-alpha.2"
tauri-plugin-opener = "2.7.2" tauri-plugin-opener = "3.0.0-alpha.2"
tauri-plugin-dialog = "2.8.3" tauri-plugin-dialog = "3.0.0-alpha.3"
tauri-plugin-fs = "2.7.1" tauri-plugin-fs = "3.0.0-alpha.2"
tauri-plugin-process = "2.4.0" tauri-plugin-process = "3.0.0-alpha.2"
tauri-plugin-deep-link = "2.6.2" tauri-plugin-deep-link = "3.0.0-alpha.2"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "3.0.0-alpha.2"
zip = "9.0.4" zip = "9.0.1"
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" }
base64 = "0.23.1" base64 = "0.23.1"
minisign-verify = "0.3.0" minisign-verify = "0.3.0"
sha2 = "0.11.1" sha2 = "0.11"
getrandom = "0.4.3" getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
gethostname = "1.1.0" gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.7.0" tauri-plugin-notification = "3.0.0-alpha.2"
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 = "3.0.0-alpha.2"
tracing = { workspace = true } tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true } tauri-plugin-devtools = { version = "3.0.0-alpha.0", 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", branch = "feat/tauri-v3" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.2"
tokio-rustls = "0.26.6" tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] } rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9" webpki-roots = "1.0.9"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
tokio-util = { version = "0.7.20", features = ["time"] } tokio-util = { version = "0.7.19", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12" specta-typescript = "=0.0.13"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.191" libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.5.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",
@@ -152,9 +150,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0" tauri-plugin-autostart = "3.0.0-alpha.2"
tauri-plugin-global-shortcut = "2.4.1" tauri-plugin-global-shortcut = "3.0.0-alpha.2"
tauri-plugin-updater = "2.14.0" tauri-plugin-updater = "3.0.0-alpha.2"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } criterion = { workspace = true }
-1
View File
@@ -70,7 +70,6 @@
"dialog:allow-open", "dialog:allow-open",
"global-shortcut:allow-is-registered", "global-shortcut:allow-is-registered",
"global-shortcut:allow-register", "global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister", "global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all", "global-shortcut:allow-unregister-all",
"process:allow-exit", "process:allow-exit",
@@ -1,5 +1,3 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let output = args let output = args
+7 -6
View File
@@ -282,9 +282,7 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome); logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
discard_and_restore(current_profile).await?; discard_and_restore(current_profile).await?;
if !matches!(outcome, ValidationOutcome::Busy) { handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
}
Ok(outcome) Ok(outcome)
} }
@@ -313,6 +311,9 @@ async fn perform_config_update(
current_value: Option<&String>, current_value: Option<&String>,
current_profile: Option<&String>, current_profile: Option<&String>,
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await; let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result { match update_result {
@@ -372,6 +373,8 @@ pub struct ProfilePatch {
#[specta(type = Option<std::string::String>)] #[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>, pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>, pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>, pub updated: Option<usize>,
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
@@ -386,6 +389,7 @@ impl From<ProfilePatch> for PrfItem {
file: patch.file, file: patch.file,
url: patch.url, url: patch.url,
selected: patch.selected, selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra, extra: patch.extra,
updated: patch.updated, updated: patch.updated,
option: patch.option, option: patch.option,
@@ -405,9 +409,6 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(ValidationOutcome::Busy); return Ok(ValidationOutcome::Busy);
} }
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles { let profiles = IProfiles {
+14 -2
View File
@@ -108,20 +108,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> { pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc(); let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref()); let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo(); let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let proxies = proxies.stringify_err()?; let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput { Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order, runtime_group_order,
group_scopes, group_scopes,
proxies, proxies,
providers: providers.ok(), providers,
})) }))
} }
+6
View File
@@ -53,6 +53,10 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>, pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>, pub extra: Option<PrfExtra>,
@@ -229,6 +233,7 @@ impl PrfItem {
desc: Some(desc), desc: Some(desc),
file: Some(file), file: Some(file),
url: None, url: None,
latency_test_urls: None,
selected: None, selected: None,
extra: None, extra: None,
option: Some(PrfOption { option: Some(PrfOption {
@@ -408,6 +413,7 @@ impl PrfItem {
desc: desc.cloned(), desc: desc.cloned(),
file: Some(file), file: Some(file),
url: Some(url.as_str().into()), url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None, selected: None,
extra, extra,
option: Some(PrfOption { option: Some(PrfOption {
+1
View File
@@ -250,6 +250,7 @@ impl IProfiles {
patch!(each, item, file); patch!(each, item, file);
patch!(each, item, url); patch!(each, item, url);
patch!(each, item, selected); patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra); patch!(each, item, extra);
patch!(each, item, updated); patch!(each, item, updated);
patch!(each, item, option); patch!(each, item, option);
+1 -1
View File
@@ -24,7 +24,7 @@ pub async fn update_launch() -> Result<()> {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
let app_handle = Handle::app_handle(); let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autolaunch(); let autostart_manager = app_handle.autostart();
if is_enable { if is_enable {
logging_error!(Type::System, "{:?}", autostart_manager.enable()); logging_error!(Type::System, "{:?}", autostart_manager.enable());
} else { } else {
+97 -6
View File
@@ -1,8 +1,8 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::Serialize; use serde::{Serialize, Serializer, ser::SerializeMap as _};
use tauri_plugin_mihomo::models::{ use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
}; };
pub struct ProxyViewInput { pub struct ProxyViewInput {
@@ -57,6 +57,28 @@ pub struct ProxyCapabilities {
pub smux: bool, pub smux: bool,
} }
#[derive(Serialize, specta::Type)]
struct ProxyLatencyHistory<'a> {
#[specta(type = Vec<crate::ipc::DelayHistory>)]
history: &'a [DelayHistory],
}
fn serialize_proxy_latency_history<S: Serializer>(
extra: &HashMap<String, Extra>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(extra.len()))?;
for (url, extra) in extra {
map.serialize_entry(
url,
&ProxyLatencyHistory {
history: &extra.history,
},
)?;
}
map.end()
}
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyGroupView { pub struct ProxyGroupView {
@@ -76,6 +98,9 @@ pub struct ProxyGroupView {
pub test_url: Option<String>, pub test_url: Option<String>,
#[specta(type = Vec<crate::ipc::DelayHistory>)] #[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(flatten)] #[serde(flatten)]
pub capabilities: ProxyCapabilities, pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>, pub members: Vec<ProxyMemberRef>,
@@ -91,6 +116,9 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType, pub proxy_type: ProxyType,
#[specta(type = Vec<crate::ipc::DelayHistory>)] #[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -256,7 +284,7 @@ impl ProxyViewBuilder {
} else { } else {
ProxyViewProviderState::Unavailable ProxyViewProviderState::Unavailable
}; };
let (mut core_groups, core_nodes) = partition_core(proxies); let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut records, core_node_ids) = build_core_records(core_nodes); let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records); let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver { let resolver = MemberResolver {
@@ -289,14 +317,14 @@ impl ProxyViewBuilder {
} }
} }
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) { fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new(); let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new(); let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies { for (name, proxy) in proxies.proxies {
if proxy.all.is_some() { if proxy.all.is_some() {
groups.insert(name, proxy); groups.insert(name, proxy);
} else { } else if !providers_available || proxy.provider_name.is_empty() {
nodes.insert(name, proxy); nodes.insert(name, proxy);
} }
} }
@@ -403,6 +431,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
now, now,
test_url, test_url,
history, history,
extra,
udp, udp,
xudp, xudp,
tfo, tfo,
@@ -435,6 +464,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon, icon,
test_url, test_url,
history, history,
extra,
capabilities: ProxyCapabilities { capabilities: ProxyCapabilities {
udp, udp,
xudp, xudp,
@@ -453,6 +483,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
icon, icon,
test_url, test_url,
history, history,
extra,
udp, udp,
xudp, xudp,
tfo, tfo,
@@ -467,6 +498,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name, name,
proxy_type, proxy_type,
history, history,
extra,
id, id,
hidden, hidden,
icon, icon,
@@ -520,3 +552,62 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
); );
standalone standalone
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into(), "B".into()],
group_scopes: ["A", "B"]
.into_iter()
.map(|name| {
(
name.into(),
GroupScope {
providers: vec![name.into()],
exclude_types: vec![],
},
)
})
.collect(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"B": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}
}}))?,
providers: Some(serde_json::from_value(json!({"providers": {
"A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]},
"B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]}
}}))?),
});
for (group, expected) in view.groups.iter().zip([32, 123]) {
let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else {
anyhow::bail!("Expected node")
};
let record = &view.records[record_id];
assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected));
assert!(
matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name)
);
}
Ok(())
}
#[test]
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into()],
group_scopes: BTreeMap::new(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "provider"}
}}))?,
providers: None,
});
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
Ok(())
}
}
+156
View File
@@ -0,0 +1,156 @@
use super::Tray;
use crate::{
Type, cmd,
config::Config,
core::{
handle,
notify::{Refresh, announce},
proxy_view::{ProxyMemberRef, ProxyNodeSource},
},
logging,
};
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
fn is_latency_test_url(raw: &str) -> bool {
let raw = raw.trim();
raw.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
}
pub(super) fn test_url(
group: &crate::core::proxy_view::ProxyGroupView,
verge: &crate::config::IVerge,
profiles: &crate::config::IProfiles,
) -> String {
let custom_url = profiles
.current
.as_ref()
.and_then(|uid| profiles.get_item(uid).ok())
.and_then(|profile| profile.latency_test_urls.as_ref())
.and_then(|urls| urls.get(group.name.as_str()))
.map(|url| url.as_str())
.filter(|url| !url.trim().is_empty());
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
let default_url = verge
.default_latency_test
.as_deref()
.filter(|url| !url.trim().is_empty());
custom_url
.or(group_url)
.or(default_url)
.filter(|&url| is_latency_test_url(url))
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
.trim()
.to_owned()
}
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
parking_lot::Mutex::new(std::collections::BTreeSet::new());
pub(super) fn is_running(group_name: &str) -> bool {
RUNNING.lock().contains(group_name)
}
pub(super) async fn test_proxy_group_delay(group_name: &str) {
if !RUNNING.lock().insert(group_name.to_owned()) {
return;
}
Tray::global().refresh_latency_item(group_name);
let _running = scopeguard::guard(group_name, |name| {
RUNNING.lock().remove(name);
Tray::global().refresh_latency_item(name);
});
use futures::{StreamExt as _, stream};
let view = match cmd::proxy::get_proxy_view().await {
Ok(view) => view,
Err(err) => {
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
return;
}
};
let verge = Config::verge().await.latest_arc();
let group = view
.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == group_name);
let Some(group) = group else {
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
return;
};
let profiles = Config::profiles().await.latest_arc();
let url = test_url(group, &verge, &profiles);
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000) as u32;
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
for member in &group.members {
match member {
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
Some(ProxyNodeSource::Provider {
provider_name,
proxy_name,
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
_ => targets.push((name.clone(), None)),
},
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
ProxyMemberRef::Unresolved { .. } => {}
}
}
let mihomo = handle::Handle::mihomo();
stream::iter(targets.into_iter().map(move |(member, provider)| {
let url = url.clone();
async move {
let result = match provider {
Some(provider_name) => {
mihomo
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
.await
}
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
};
if let Err(err) = result {
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
}
}
}))
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
.for_each(|()| std::future::ready(()))
.await;
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
}
announce(Refresh::Proxies);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_urls_require_a_valid_http_host_and_port() {
for invalid in [
"http://?",
"http://host:invalid",
"http://bad host/",
"ftp://localhost",
"http:localhost",
] {
assert!(!is_latency_test_url(invalid), "{invalid}");
}
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
assert!(is_latency_test_url(valid), "{valid}");
}
}
}
+1
View File
@@ -32,6 +32,7 @@ define_menu! {
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
profiles => PROFILES, "tray_profiles", "tray.profiles", profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies", proxies => PROXIES, "tray_proxies", "tray.proxies",
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
+386 -161
View File
@@ -1,5 +1,6 @@
use crate::config::{IProfilePreview, IVerge}; use crate::config::{IProfilePreview, IVerge};
use crate::core::lightweight; use crate::core::lightweight;
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
use crate::core::tray::menu_def::TrayAction; use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler; use crate::process::AsyncHandler;
use crate::singleton; use crate::singleton;
@@ -14,7 +15,6 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error; use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs; use tokio::fs;
use super::handle; use super::handle;
@@ -23,11 +23,13 @@ use smartstring::alias::String;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tauri::Manager as _;
use tauri::{ use tauri::{
AppHandle, Wry, AppHandle, DynRuntime,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
}; };
mod latency;
mod menu_def; mod menu_def;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod speed_task; mod speed_task;
@@ -35,7 +37,7 @@ use menu_def::{MenuIds, MenuTexts};
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑) // TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>); type ProxyMenuItem = (Option<Submenu<DynRuntime>>, Vec<Box<dyn IsMenuItem<DynRuntime>>>);
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300; 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";
@@ -55,8 +57,25 @@ enum IconKind {
Tun, Tun,
} }
struct LatencyMenuItem {
item: MenuItem<DynRuntime>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<DynRuntime>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray { pub struct Tray {
limiter: SystemLimiter, limiter: SystemLimiter,
proxy_hover_limiter: SystemLimiter,
menu_update: tokio::sync::Mutex<()>,
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController, speed_controller: speed_task::TraySpeedController,
} }
@@ -130,6 +149,9 @@ impl Default for Tray {
fn default() -> Self { fn default() -> Self {
Self { Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
menu_update: tokio::sync::Mutex::new(()),
latency_items: parking_lot::Mutex::new(HashMap::new()),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController::new(), speed_controller: speed_task::TraySpeedController::new(),
} }
@@ -195,6 +217,7 @@ impl Tray {
} }
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> { async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
let _update = self.menu_update.lock().await;
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else { let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found"); logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(()); return Ok(());
@@ -219,29 +242,133 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); 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 (menu, latency_items) = create_tray_menu(
Type::Tray, app_handle,
tray.set_menu(Some( Some(mode.as_str()),
create_tray_menu( *system_proxy,
app_handle, *tun_mode,
Some(mode.as_str()), tun_mode_available,
*system_proxy, profiles_preview,
*tun_mode, TrayMenuOptions {
tun_mode_available, is_lightweight_mode,
profiles_preview, include_proxy_groups,
TrayMenuOptions { },
is_lightweight_mode, )
include_proxy_groups, .await?;
}, tray.set_menu(Some(menu))?;
) let groups: Vec<_> = latency_items.keys().cloned().collect();
.await?, *self.latency_items.lock() = latency_items;
)) for group in groups {
); self.refresh_latency_item(&group);
}
logging!(debug, Type::Tray, "托盘菜单更新成功"); logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(()) Ok(())
} }
async fn refresh_proxy_items(&'static self) {
let Ok(update) = self.menu_update.try_lock() else {
return;
};
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
return;
}
let Ok(Ok(view)) = tokio::time::timeout(
Duration::from_secs(1),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
else {
return;
};
if self
.latency_items
.lock()
.values()
.any(|entry| entry.provider_state != view.provider_state)
{
// Provider fallback changes member identities; rebuild through the existing queue.
drop(update);
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
return;
}
let verge = Config::verge().await.latest_arc();
let profiles = Config::profiles().await.latest_arc();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
// Keep rebuilding serialized until these handles have received their snapshot.
let _update = update;
let mut items = self.latency_items.lock();
for (group_name, entry) in items.iter_mut() {
let Some(group) = find_group(&view, group_name) else {
continue;
};
let url = latency::test_url(group, &verge, &profiles);
let mut members = HashMap::with_capacity(group.members.len());
for member in &group.members {
members
.entry((member_name(member), member_provider(&view, member)))
.or_insert(member);
}
for proxy in &mut entry.proxies {
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
continue;
};
let text = proxy_item_text(&view, member, &url, &verge);
let checked = group.now.as_ref() == Some(&proxy.name);
if text != proxy.text {
match proxy.item.set_text(&text) {
Ok(()) => proxy.text = text,
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
}
}
match proxy.item.is_checked() {
Ok(current) if current != checked => {
logging_error!(Type::Tray, proxy.item.set_checked(checked))
}
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
_ => {}
}
}
}
})
);
}
fn refresh_latency_item(&self, group_name: &str) {
let group_name = group_name.to_owned();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
Self::global().refresh_latency_item_on_main_thread(&group_name);
})
);
}
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
let running = latency::is_running(group_name);
let item = {
let mut items = self.latency_items.lock();
items.get_mut(group_name).and_then(|entry| {
if entry.running == running {
return None;
}
entry.running = running;
Some(entry.item.clone())
})
};
if let Some(item) = item {
let label = if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
clash_verge_i18n::t!("tray.testLatency")
};
logging_error!(Type::Tray, item.set_text(label));
logging_error!(Type::Tray, item.set_enabled(!running));
}
}
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() { if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -440,7 +567,7 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
fn create_profile_menu_item( fn create_profile_menu_item(
app_handle: &AppHandle, app_handle: &AppHandle,
profiles_preview: Vec<IProfilePreview<'_>>, profiles_preview: Vec<IProfilePreview<'_>>,
) -> Result<Vec<CheckMenuItem<Wry>>> { ) -> Result<Vec<CheckMenuItem<DynRuntime>>> {
profiles_preview profiles_preview
.into_iter() .into_iter()
.map(|profile| { .map(|profile| {
@@ -457,102 +584,181 @@ fn create_profile_menu_item(
.collect() .collect()
} }
const UNTESTED_DELAY_TEXT: &str = "-ms";
const TIMEOUT_DELAY_TEXT: &str = "T/O";
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
const fn member_name(member: &ProxyMemberRef) -> &str {
match member {
ProxyMemberRef::Node { name, .. }
| ProxyMemberRef::Group { name }
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
}
}
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
view.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == name)
}
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
/// node the core tested on its own still shows a value.
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
view.records
.get(record_id)
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
/// A group row shows the delay of the node the group currently selects. The core never writes a
/// history for the group itself, so `now` is followed until the chain reaches a node.
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
let mut current = group_name;
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
let Some(group) = find_group(view, current) else { break };
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
break;
};
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
break;
};
match member {
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => current = name.as_str(),
ProxyMemberRef::Unresolved { .. } => break,
}
}
find_group(view, group_name)
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
let ProxyMemberRef::Node { record_id, .. } = member else {
return None;
};
match &view.records.get(record_id)?.source {
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
ProxyNodeSource::Core { .. } => None,
}
}
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000);
let delay = match member {
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
ProxyMemberRef::Unresolved { .. } => None,
};
let delay_text = delay.map_or_else(
|| UNTESTED_DELAY_TEXT.to_owned(),
|delay| {
if delay == 0 || u32::from(delay) >= timeout as u32 {
TIMEOUT_DELAY_TEXT.to_owned()
} else {
format!("{delay}ms")
}
},
);
format!("{} | {delay_text}", member_name(member))
}
fn create_subcreate_proxy_menu_item( fn create_subcreate_proxy_menu_item(
app_handle: &AppHandle, app_handle: &AppHandle,
proxy_mode: &str, proxy_mode: &str,
proxy_group_order_map: Option<HashMap<String, usize>>, view: Option<ProxyViewV1>,
proxy_nodes_data: Option<Proxies>, latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
) -> Vec<Submenu<Wry>> { texts: &MenuTexts,
let proxy_submenus: Vec<Submenu<Wry>> = { verge: &IVerge,
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new(); profiles: &crate::config::IProfiles,
) -> Vec<Submenu<DynRuntime>> {
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 let Some(view) = view else { return Vec::new() };
if let Some(proxy_nodes_data) = proxy_nodes_data { view.groups
for (group_name, group_data) in proxy_nodes_data.proxies.iter() { .iter()
let should_show = match proxy_mode { .chain(view.global.iter())
"global" => group_name == "GLOBAL", .filter_map(|group| {
_ => group_name != "GLOBAL", if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
} && !group_data.hidden.unwrap_or_default(); return None;
if !should_show {
continue;
}
let Some(all_proxies) = group_data.all.as_ref() else {
continue;
};
let now_proxy = group_data.now.as_deref().unwrap_or_default();
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
let delay_text = proxy_nodes_data
.proxies
.get(proxy_str)
.and_then(|h| h.history.last())
.map(|h| match h.delay {
0 => "-ms".into(),
delay if delay >= 10000 => "-ms".into(),
_ => format!("{}ms", h.delay),
})
.unwrap_or_else(|| "-ms".into());
let display_text = format!("{} | {}", proxy_str, delay_text);
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
.ok()
})
.collect();
if group_items.is_empty() {
continue;
}
let group_display_name = group_name.to_string();
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group_name),
group_display_name,
true,
&group_items_refs,
) {
let insertion_index = submenus.len();
submenus.push((group_name.into(), insertion_index, submenu));
} else {
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
}
} }
} let url = latency::test_url(group, verge, profiles);
let proxies: Vec<ProxyLatencyItem> = group
if let Some(order_map) = proxy_group_order_map.as_ref() { .members
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| { .iter()
match (order_map.get(name_a), order_map.get(name_b)) { .filter_map(|member| {
(Some(index_a), Some(index_b)) => index_a.cmp(index_b), let name = member_name(member);
(Some(_), None) => std::cmp::Ordering::Less, let text = proxy_item_text(&view, member, &url, verge);
(None, Some(_)) => std::cmp::Ordering::Greater, let checked = group.now.as_deref() == Some(name);
(None, None) => original_index_a.cmp(original_index_b), CheckMenuItem::with_id(
} app_handle,
}); format!("proxy_{}_{}", group.name, name),
} &text,
true,
submenus.into_iter().map(|(_, _, submenu)| submenu).collect() checked,
}; None::<&str>,
proxy_submenus )
.map(|item| ProxyLatencyItem {
item,
name: name.to_owned(),
provider_name: member_provider(&view, member).map(str::to_owned),
text,
})
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
.ok()
})
.collect();
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
if group_items.is_empty() {
return None;
}
let running = latency::is_running(&group.name);
let test_item = MenuItem::with_id(
app_handle,
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
texts.test_latency.clone()
},
!running,
None::<&str>,
)
.ok()?;
latency_items.insert(
group.name.clone(),
LatencyMenuItem {
item: test_item.clone(),
running,
proxies,
provider_state: view.provider_state,
},
);
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
let mut items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<DynRuntime>));
Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group.name),
&group.name,
true,
&items,
)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
.ok()
})
.collect()
} }
fn create_proxy_menu_item( fn create_proxy_menu_item(
app_handle: &AppHandle, app_handle: &AppHandle,
show_proxy_groups_inline: bool, show_proxy_groups_inline: bool,
proxy_submenus: Vec<Submenu<Wry>>, proxy_submenus: Vec<Submenu<DynRuntime>>,
proxies_text: &str, proxies_text: &str,
) -> Result<ProxyMenuItem> { ) -> Result<ProxyMenuItem> {
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline { let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
@@ -560,13 +766,13 @@ fn create_proxy_menu_item(
None, None,
proxy_submenus proxy_submenus
.into_iter() .into_iter()
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<Wry>>) .map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<DynRuntime>>)
.collect(), .collect(),
) )
} else if !proxy_submenus.is_empty() { } else if !proxy_submenus.is_empty() {
let proxy_submenu_refs: Vec<&dyn IsMenuItem<Wry>> = proxy_submenus let proxy_submenu_refs: Vec<&dyn IsMenuItem<DynRuntime>> = proxy_submenus
.iter() .iter()
.map(|submenu| submenu as &dyn IsMenuItem<Wry>) .map(|submenu| submenu as &dyn IsMenuItem<DynRuntime>)
.collect(); .collect();
( (
@@ -593,66 +799,53 @@ async fn create_tray_menu(
tun_mode_available: bool, tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>, profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions, options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> { ) -> Result<(
tauri::menu::Menu<DynRuntime>,
HashMap<std::string::String, LatencyMenuItem>,
)> {
let current_proxy_mode = mode.unwrap_or(""); let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc(); let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups = let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); 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 // Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups { let proxy_view = if fetch_proxy_groups {
let proxy_nodes_data = tokio::time::timeout(
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies()) Duration::from_millis(1000),
.await cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
.map_or(None, |res| res.ok()); )
.await
let runtime = Config::runtime().await.latest_arc(); .ok()
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| { .and_then(Result::ok)
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 { } else {
(None, None) None
}; };
if fetch_proxy_groups { if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc(); verge_settings = Config::verge().await.latest_arc();
} }
let profiles_config = Config::profiles().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 = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "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);
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
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<DynRuntime>> = create_profile_menu_item(app_handle, profiles_preview)?;
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<DynRuntime>> = profile_menu_items
.iter() .iter()
.map(|item| item as &dyn IsMenuItem<Wry>) .map(|item| item as &dyn IsMenuItem<DynRuntime>)
.collect(); .collect();
let open_window = &MenuItem::with_id( let open_window = &MenuItem::with_id(
@@ -705,9 +898,9 @@ async fn create_tray_menu(
outbound_modes_label.as_str(), outbound_modes_label.as_str(),
true, true,
&[ &[
rule_mode as &dyn IsMenuItem<Wry>, rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<Wry>, global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<Wry>, direct_mode as &dyn IsMenuItem<DynRuntime>,
], ],
)?) )?)
}; };
@@ -720,9 +913,17 @@ async fn create_tray_menu(
&profile_menu_items_refs, &profile_menu_items_refs,
)?; )?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups { let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus = let proxy_sub_menus = create_subcreate_proxy_menu_item(
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
match tray_proxy_groups_display_mode { match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -812,7 +1013,7 @@ async fn create_tray_menu(
&texts.more, &texts.more,
true, true,
&[ &[
copy_env as &dyn IsMenuItem<Wry>, copy_env as &dyn IsMenuItem<DynRuntime>,
close_all_connections, close_all_connections,
restart_clash, restart_clash,
restart_app, restart_app,
@@ -829,13 +1030,13 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?; let separator = &PredefinedMenuItem::separator(app_handle)?;
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator]; let mut menu_items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![open_window, separator];
if show_outbound_modes_inline { if show_outbound_modes_inline {
menu_items.extend_from_slice(&[ menu_items.extend_from_slice(&[
rule_mode as &dyn IsMenuItem<Wry>, rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<Wry>, global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<Wry>, direct_mode as &dyn IsMenuItem<DynRuntime>,
]); ]);
} else if let Some(ref outbound_modes) = outbound_modes { } else if let Some(ref outbound_modes) = outbound_modes {
menu_items.push(outbound_modes); menu_items.push(outbound_modes);
@@ -855,21 +1056,36 @@ async fn create_tray_menu(
menu_items.extend_from_slice(&[ menu_items.extend_from_slice(&[
separator, separator,
system_proxy as &dyn IsMenuItem<Wry>, system_proxy as &dyn IsMenuItem<DynRuntime>,
tun_mode as &dyn IsMenuItem<Wry>, tun_mode as &dyn IsMenuItem<DynRuntime>,
separator, separator,
lightweight_mode as &dyn IsMenuItem<Wry>, lightweight_mode as &dyn IsMenuItem<DynRuntime>,
open_dir as &dyn IsMenuItem<Wry>, open_dir as &dyn IsMenuItem<DynRuntime>,
more as &dyn IsMenuItem<Wry>, more as &dyn IsMenuItem<DynRuntime>,
separator, separator,
quit as &dyn IsMenuItem<Wry>, quit as &dyn IsMenuItem<DynRuntime>,
]); ]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok(menu) Ok((menu, latency_items))
} }
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
let refresh_proxies = match tray_event {
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
TrayIconEvent::Click {
button: MouseButton::Left | MouseButton::Right,
button_state: MouseButtonState::Down,
..
} => true,
_ => false,
};
if refresh_proxies {
AsyncHandler::spawn(|| async {
Tray::global().refresh_proxy_items().await;
});
}
if matches!( if matches!(
tray_event, tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. } TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -991,6 +1207,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}; };
feat::toggle_proxy_profile(profile_index.into()).await; feat::toggle_proxy_profile(profile_index.into()).await;
} }
id if id.starts_with(MenuIds::TEST_LATENCY) => {
let group_name = id
.strip_prefix(MenuIds::TEST_LATENCY)
.and_then(|rest| rest.strip_prefix('_'));
if let Some(group_name) = group_name {
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
latency::test_proxy_group_delay(group_name).await;
}
}
id if id.starts_with("proxy_") => { id if id.starts_with("proxy_") => {
let rest = match id.strip_prefix("proxy_") { let rest = match id.strip_prefix("proxy_") {
Some(r) => r, Some(r) => r,
+2 -2
View File
@@ -132,7 +132,7 @@ impl TraySpeedController {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) { if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
let result = tray.with_inner_tray_icon(|inner| { let result = tray.with_inner_blocking(|inner| {
if let Some(status_item) = inner.ns_status_item() { if let Some(status_item) = inner.ns_status_item() {
tray_speed::clear_speed_attributed_title(&status_item); tray_speed::clear_speed_attributed_title(&status_item);
} }
@@ -167,7 +167,7 @@ impl TraySpeedController {
fn apply_tray_speed(up: u64, down: u64) { fn apply_tray_speed(up: u64, down: u64) {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) { if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
let result = tray.with_inner_tray_icon(move |inner| { let result = tray.with_inner_blocking(move |inner| {
if let Some(status_item) = inner.ns_status_item() { if let Some(status_item) = inner.ns_status_item() {
tray_speed::set_speed_attributed_title(&status_item, up, down); tray_speed::set_speed_attributed_title(&status_item, up, down);
} }
+3 -1
View File
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}"); logging!(info, Type::Window, "Window toggle result: {result:?}");
} }
pub async fn quit() { pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
logging!(debug, Type::System, "启动退出流程"); logging!(debug, Type::System, "启动退出流程");
// 设置退出标志 // 设置退出标志
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
@@ -165,11 +165,13 @@ pub async fn quit() {
NoticeStatus::AppQuitCoreStopFailed, NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(), cleanup_result.stop_error.unwrap_or_default(),
); );
return clash_verge_signal::ShutdownOutcome::Canceled;
} }
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 });
clash_verge_signal::ShutdownOutcome::Committed
} }
pub async fn clean_async() -> CleanupResult { pub async fn clean_async() -> CleanupResult {
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::cmd; use crate::cmd;
pub fn builder() -> tauri_specta::Builder<tauri::Wry> { pub fn builder() -> tauri_specta::Builder<tauri::DynRuntime> {
tauri_specta::Builder::new() tauri_specta::Builder::new()
.types(&platform_types()) .types(&platform_types())
.commands(tauri_specta::collect_commands![ .commands(tauri_specta::collect_commands![
tauri_plugin_clash_verge_sysinfo::commands::get_system_info, tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime, tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::Wry>, tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::DynRuntime>,
cmd::probe_listener, cmd::probe_listener,
cmd::save_proxy_ports, cmd::save_proxy_ports,
cmd::get_sys_proxy, cmd::get_sys_proxy,
+3 -4
View File
@@ -40,7 +40,7 @@ mod app_init {
} }
/// Setup plugins for the Tauri builder /// Setup plugins for the Tauri builder
pub fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> { pub fn setup_plugins(builder: tauri::Builder) -> tauri::Builder {
#[allow(unused_mut)] #[allow(unused_mut)]
let mut builder = builder let mut builder = builder
.plugin(tauri_plugin_clash_verge_sysinfo::init()) .plugin(tauri_plugin_clash_verge_sysinfo::init())
@@ -124,7 +124,7 @@ mod app_init {
Ok(()) Ok(())
} }
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static { pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static {
{ {
let builder = crate::ipc::builder(); let builder = crate::ipc::builder();
move |invoke| builder.invoke_handler()(invoke) move |invoke| builder.invoke_handler()(invoke)
@@ -181,7 +181,7 @@ pub fn run() -> std::process::ExitCode {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
utils::linux::workarounds::apply_wayland_webkit_fix(); utils::linux::workarounds::apply_wayland_webkit_fix();
let builder = app_init::setup_plugins(tauri::Builder::default()) let builder = app_init::setup_plugins(tauri::Builder::default().runtime(tauri_runtime_wry::Wry::default()))
.setup(|app| { .setup(|app| {
// Logger may not be ready yet, so mirror setup panics to stderr. // Logger may not be ready yet, so mirror setup panics to stderr.
fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) { fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) {
@@ -230,7 +230,6 @@ pub fn run() -> std::process::ExitCode {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
#[cfg(target_os = "linux")]
resolve::init_signal(); resolve::init_signal();
})) { })) {
log_setup_panic("window-core", panic); log_setup_panic("window-core", panic);
+1 -12
View File
@@ -1,22 +1,11 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::ExitCode; use std::process::ExitCode;
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
#[path = "../../scripts/perf/app.rs"]
mod perf;
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
fn main() -> ExitCode {
perf::run()
}
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode { fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 4); let worker_limit = std::cmp::min(default_parallelism, 8);
let blocking_limit = 2 * worker_limit; let blocking_limit = 2 * worker_limit;
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
+1 -2
View File
@@ -20,6 +20,7 @@ use crate::{
utils::{init, server, window_manager::WindowManager}, utils::{init, server, window_manager::WindowManager},
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod dns; pub mod dns;
@@ -188,8 +189,6 @@ async fn init_silent_updater() {
logging!(info, Type::Setup, "Silent updater initialized"); logging!(info, Type::Setup, "Silent updater initialized");
} }
// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
#[cfg(target_os = "linux")]
pub(crate) fn init_signal() { pub(crate) fn init_signal() {
logging!(debug, Type::Setup, "Initializing signal handlers..."); logging!(debug, Type::Setup, "Initializing signal handlers...");
clash_verge_signal::register(feat::quit); clash_verge_signal::register(feat::quit);
+4 -1
View File
@@ -132,7 +132,10 @@ pub fn take_webview_needs_reload() -> bool {
/// reload visible webviews immediately, and defer a hidden main-window reload until activation. /// reload visible webviews immediately, and defer a hidden main-window reload until activation.
/// Registering this callback replaces Tauri's default automatic reload. /// Registering this callback replaces Tauri's default automatic reload.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn on_web_content_process_terminated(webview: &tauri::Webview) { pub fn on_web_content_process_terminated(
webview: &tauri::Webview,
_termination: &tauri::webview::WebContentProcessTermination,
) {
if handle::Handle::global().is_exiting() { if handle::Handle::global().is_exiting() {
return; return;
} }
+52 -1
View File
@@ -39,6 +39,8 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
const PAC_INITIAL_AVAILABLE: bool = false; const PAC_INITIAL_AVAILABLE: bool = false;
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE); static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
static COMMANDS_READY: AtomicBool = AtomicBool::new(false); static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
#[cfg(feature = "verge-dev")]
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingletonDisposition { pub enum SingletonDisposition {
@@ -162,12 +164,55 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
.untuple_one() .untuple_one()
} }
#[cfg(feature = "verge-dev")]
fn dev_quit_route<F>(
token: String,
commands_ready: &'static AtomicBool,
quit_requested: &'static AtomicBool,
trigger: F,
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
where
F: Fn() + Clone + Send + Sync + 'static,
{
instance_auth(token)
.and(warp::path!("commands" / "dev" / "quit"))
.and(warp::post())
.and_then(move || {
let trigger = trigger.clone();
async move {
if !commands_ready.load(Ordering::Acquire) {
return Ok::<_, warp::Rejection>(warp::reply::with_status(
String::from("starting"),
warp::http::StatusCode::SERVICE_UNAVAILABLE,
));
}
if quit_requested
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
trigger();
}
Ok(warp::reply::with_status(
String::from("accepted"),
warp::http::StatusCode::ACCEPTED,
))
}
})
}
#[cfg(feature = "verge-dev")]
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
quit_requested.store(false, Ordering::Release);
}
}
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel();
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx))); let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
#[cfg(feature = "verge-dev")] #[cfg(feature = "verge-dev")]
let auth = instance_auth(token); let auth = instance_auth(token.clone());
#[cfg(not(feature = "verge-dev"))] #[cfg(not(feature = "verge-dev"))]
let auth = instance_auth(token); let auth = instance_auth(token);
@@ -233,6 +278,12 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
}); });
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac); let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
#[cfg(feature = "verge-dev")]
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
AsyncHandler::spawn(|| async {
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
});
}));
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
warp::serve(commands) warp::serve(commands)
.incoming(listener) .incoming(listener)
+11 -33
View File
@@ -27,12 +27,8 @@ const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0; const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0; const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
/// (button_height, mono_advance, attributes).
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
thread_local! { thread_local! {
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) }; static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
static CACHED_ATTRS: RefCell<Option<CachedAttrs>> = const { RefCell::new(None) };
} }
fn format_tray_speed(up: u64, down: u64) -> String { fn format_tray_speed(up: u64, down: u64) -> String {
@@ -78,26 +74,8 @@ fn create_attributed_string(
} }
} }
/// Attributes and per-glyph advance, rebuilt only when the button height changes. fn status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 {
fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) { (attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH)
CACHED_ATTRS.with(|cache| {
if let Some((height, advance, attrs)) = cache.borrow().as_ref()
&& *height == button_height
{
return (*advance, attrs.clone());
}
let attrs = build_attributes(button_height);
let probe = NSString::from_str("0");
let advance = create_attributed_string(&probe, Some(&attrs)).size().width;
*cache.borrow_mut() = Some((button_height, advance, attrs.clone()));
(advance, attrs)
})
}
/// Longest line times the monospaced advance; matches `size().width` for the ASCII-only speed text.
fn arithmetic_width(text: &str, advance: f64) -> f64 {
let glyphs = text.split('\n').map(str::len).max().unwrap_or(0) as f64;
glyphs * advance
} }
fn sync_click_target_frame(button: &NSStatusBarButton) { fn sync_click_target_frame(button: &NSStatusBarButton) {
@@ -111,7 +89,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
} }
/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not. /// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not.
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) { fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else { let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用"); logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
return; return;
@@ -119,19 +97,17 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, sh
let Some(button) = status_item.button(mtm) else { let Some(button) = status_item.button(mtm) else {
return; return;
}; };
let ns_string = NSString::from_str(text);
let attr_str = if show_speed { let attr_str = if show_speed {
let (advance, attrs) = cached_attributes(button.bounds().size.height); let attrs = build_attributes(button.bounds().size.height);
let attrs: &NSDictionary<NSString, AnyObject> = &attrs; let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
let attr_str = create_attributed_string(&ns_string, Some(attrs)); let attr_str = create_attributed_string(text, Some(attrs));
let width = arithmetic_width(text, advance); let length = status_item_length_for_speed(&attr_str);
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
if status_item.length() != length { if status_item.length() != length {
status_item.setLength(length); status_item.setLength(length);
} }
attr_str attr_str
} else { } else {
create_attributed_string(&ns_string, None) create_attributed_string(text, None)
}; };
button.setAttributedTitle(&attr_str); button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button); sync_click_target_frame(&button);
@@ -145,7 +121,8 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return; return;
} }
*last = speed_text; *last = speed_text;
apply_status_item_attributed_title(status_item, last.as_str(), true); let ns_string = NSString::from_str(last.as_str());
apply_status_item_attributed_title(status_item, &ns_string, true);
}); });
} }
@@ -153,6 +130,7 @@ pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
LAST_DISPLAY_STR.with(|last| { LAST_DISPLAY_STR.with(|last| {
last.borrow_mut().clear(); last.borrow_mut().clear();
}); });
let empty = NSString::from_str("");
status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH); status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH);
apply_status_item_attributed_title(status_item, "", false); apply_status_item_attributed_title(status_item, &empty, false);
} }
+9 -9
View File
@@ -4,7 +4,7 @@ use clash_verge_logging::{Type, logging};
use std::pin::Pin; use std::pin::Pin;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
use tauri::{Manager as _, WebviewWindow, Wry}; use tauri::{Manager as _, WebviewWindow};
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult { pub enum WindowOperationResult {
@@ -50,7 +50,7 @@ impl WindowManager {
handle::Handle::global().set_activation_policy_regular(); handle::Handle::global().set_activation_policy_regular();
} }
fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) { fn get_main_window_with_state() -> (Option<WebviewWindow>, WindowState) {
let Some(window) = Self::get_main_window() else { let Some(window) = Self::get_main_window() else {
return (None, WindowState::NotExist); return (None, WindowState::NotExist);
}; };
@@ -76,7 +76,7 @@ impl WindowManager {
Self::get_main_window_with_state().1 Self::get_main_window_with_state().1
} }
pub fn get_main_window() -> Option<WebviewWindow<Wry>> { pub fn get_main_window() -> Option<WebviewWindow> {
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.get_webview_window("main") app_handle.get_webview_window("main")
} }
@@ -147,7 +147,7 @@ impl WindowManager {
} }
} }
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult { fn hide_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口可见,将隐藏窗口"); logging!(debug, Type::Window, "窗口可见,将隐藏窗口");
if let Some(window) = window { if let Some(window) = window {
match window.close() { match window.close() {
@@ -166,7 +166,7 @@ impl WindowManager {
} }
} }
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult { fn activate_existing_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口"); logging!(debug, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
if let Some(window) = window { if let Some(window) = window {
Self::activate_window(window) Self::activate_window(window)
@@ -176,7 +176,7 @@ impl WindowManager {
} }
} }
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult { fn activate_window(window: &WebviewWindow) -> WindowOperationResult {
logging!(debug, Type::Window, "开始激活窗口"); logging!(debug, Type::Window, "开始激活窗口");
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
Self::set_macos_activation_policy_regular(); Self::set_macos_activation_policy_regular();
@@ -233,15 +233,15 @@ impl WindowManager {
} }
} }
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool { pub fn is_main_window_visible(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false) window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
} }
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool { pub fn is_main_window_focused(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false) window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
} }
fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool { fn is_main_window_minimized(window: Option<&WebviewWindow>) -> bool {
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false) window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
} }
@@ -47,7 +47,10 @@ import {
useCoreDataStatus, useCoreDataStatus,
useProxiesData, useProxiesData,
} from '@/providers/app-data-context' } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { profileTestUrls } from '@/services/proxy-test-url'
import { import {
findCurrentGroupMember, findCurrentGroupMember,
getRecord, getRecord,
@@ -675,6 +678,20 @@ export const CurrentProxyCard = () => {
[proxyView], [proxyView],
) )
useEffect(() => {
if (!selectedGroup) return
const customUrl = currentProfile
? profileTestUrls(currentProfile)[selectedGroup.name]
: undefined
delayManager.setUrl(
selectedGroup.name,
customUrl?.trim() ||
selectedGroup.testUrl?.trim() ||
verge?.default_latency_test?.trim() ||
'http://cp.cloudflare.com/generate_204',
)
}, [selectedGroup, currentProfile, verge?.default_latency_test])
const unsortedProxyOptions = useMemo( const unsortedProxyOptions = useMemo(
() => optionsForGroup(selectedGroup), () => optionsForGroup(selectedGroup),
[optionsForGroup, selectedGroup], [optionsForGroup, selectedGroup],
@@ -907,6 +924,12 @@ export const CurrentProxyCard = () => {
} }
refreshProxy() refreshProxy()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
})
}) })
const proxyOptions = useMemo( const proxyOptions = useMemo(
+1 -1
View File
@@ -101,7 +101,7 @@ export const SystemInfoCard = () => {
try { try {
const result = await triggerCheckUpdate() const result = await triggerCheckUpdate()
const info = result.data const info = result.data
if (!info?.available) { if (!info) {
showNotice.success( showNotice.success(
'settings.components.verge.advanced.notifications.latestVersion', 'settings.components.verge.advanced.notifications.latestVersion',
) )
+1 -1
View File
@@ -16,7 +16,7 @@ export const UpdateButton = (props: Props) => {
const { updateInfo } = useUpdate() const { updateInfo } = useUpdate()
if (!updateInfo?.available) return null if (!updateInfo) return null
return ( return (
<> <>
-7
View File
@@ -75,7 +75,6 @@ interface ProxyChainProps {
onMarkUnsavedChanges?: () => void onMarkUnsavedChanges?: () => void
mode?: string mode?: string
selectedGroup?: string | null selectedGroup?: string | null
onExitChainMode: () => void
} }
interface ProxyChainItemProps { interface ProxyChainItemProps {
@@ -332,7 +331,6 @@ export const ProxyChain = ({
onMarkUnsavedChanges, onMarkUnsavedChanges,
mode, mode,
selectedGroup, selectedGroup,
onExitChainMode,
}: ProxyChainProps) => { }: ProxyChainProps) => {
const theme = useTheme() const theme = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
@@ -698,18 +696,13 @@ export const ProxyChain = ({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 2,
height: '100%', height: '100%',
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}} }}
> >
<Typography>{t('proxies.page.chain.empty')}</Typography> <Typography>{t('proxies.page.chain.empty')}</Typography>
<Button size="small" variant="outlined" onClick={onExitChainMode}>
{t('shared.actions.close')}
</Button>
</Box> </Box>
) : ( ) : (
<DragDropProvider <DragDropProvider
+11 -6
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded' import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded' import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded' import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps, TextField } from '@mui/material' import { Box, IconButton, type SxProps } from '@mui/material'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react' import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network' import { isValidLatencyTestUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base' import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort' import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state' import type { HeadState } from './use-head-state'
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url]) }, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)} )}
{textState === 'url' && ( {textState === 'url' && (
<TextField <ProxyTestUrlInput
name={groupName}
inputRef={inputRef} inputRef={inputRef}
autoComplete="new-password" autoComplete="new-password"
hiddenLabel hiddenLabel
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
}} }}
onChange={(e) => onHeadState({ testUrl: e.target.value })} onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/> />
)} )}
@@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') { if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) { if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl') showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return return
} }
@@ -77,7 +77,6 @@ interface GroupSelectMenuProps {
interface ProxyGroupsChainProps { interface ProxyGroupsChainProps {
mode: string mode: string
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
availableGroups: any[] availableGroups: any[]
activeSelectedGroup: string | null activeSelectedGroup: string | null
showScrollTop: boolean showScrollTop: boolean
@@ -306,7 +305,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
const { const {
mode, mode,
chainConfigData, chainConfigData,
onExitChainMode,
availableGroups, availableGroups,
activeSelectedGroup, activeSelectedGroup,
showScrollTop, showScrollTop,
@@ -523,7 +521,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
chainConfigData={chainConfigData} chainConfigData={chainConfigData}
mode={mode} mode={mode}
selectedGroup={activeSelectedGroup} selectedGroup={activeSelectedGroup}
onExitChainMode={onExitChainMode}
/> />
</Box> </Box>
</Box> </Box>
+16 -24
View File
@@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection' import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context' import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { import {
isInteractableMember, isInteractableMember,
resolveMember, resolveMember,
@@ -42,7 +44,6 @@ import {
ProxyGroupNavigator, ProxyGroupNavigator,
} from './proxy-group-navigator' } from './proxy-group-navigator'
import { ProxyRender } from './proxy-render' import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import { import {
PROXY_GROUP_HEADER_SENSORS, PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout, useProxyGroupHeaderLayout,
@@ -69,7 +70,6 @@ interface Props {
mode: string mode: string
isChainMode?: boolean isChainMode?: boolean
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
} }
function useEmptyRenderList() { function useEmptyRenderList() {
@@ -90,11 +90,11 @@ function useProxyRenderState(
) { ) {
const { verge } = useVerge() const { verge } = useVerge()
const { proxyView } = useProxiesData() const { proxyView } = useProxiesData()
const { const { renderList, onProxies, onHeadState } = useRenderList(
renderList, mode,
onProxies, isChainMode,
onHeadState: setListHeadState, activeSelectedGroup,
} = useRenderList(mode, isChainMode, activeSelectedGroup) )
const scrollPositionKey = useMemo( const scrollPositionKey = useMemo(
() => () =>
isChainMode isChainMode
@@ -103,14 +103,6 @@ function useProxyRenderState(
[activeSelectedGroup, isChainMode, mode], [activeSelectedGroup, isChainMode, mode],
) )
const onHeadState = useCallback(
(groupName: string, patch: Partial<HeadState>) => {
if (patch.open) onProxies()
setListHeadState(groupName, patch)
},
[onProxies, setListHeadState],
)
const timeout = verge?.default_latency_timeout || 10000 const timeout = verge?.default_latency_timeout || 10000
const handleCheckAll = useStableCallback( const handleCheckAll = useStableCallback(
@@ -143,6 +135,12 @@ function useProxyRenderState(
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally { } finally {
onProxies() onProxies()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxyGroups] Failed to sync tray latency:', error)
})
} }
}), }),
) )
@@ -192,9 +190,8 @@ function useProxyRenderState(
function ChainProxyGroups(props: { function ChainProxyGroups(props: {
mode: string mode: string
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
}) { }) {
const { mode, chainConfigData, onExitChainMode } = props const { mode, chainConfigData } = props
const { proxyView } = useProxiesData() const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null) const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@@ -349,7 +346,6 @@ function ChainProxyGroups(props: {
<ProxyGroupsChain <ProxyGroupsChain
mode={mode} mode={mode}
chainConfigData={chainConfigData} chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
availableGroups={availableGroups} availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup} activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop} showScrollTop={showScrollTop}
@@ -621,7 +617,7 @@ function NormalProxyGroups(props: { mode: string }) {
} }
export const ProxyGroups = (props: Props) => { export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props const { mode, isChainMode = false, chainConfigData } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles() const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData() const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData() const { isRunningModePending } = useSystemData()
@@ -643,11 +639,7 @@ export const ProxyGroups = (props: Props) => {
return <ProxyEmptyState reason={listState.reason} /> return <ProxyEmptyState reason={listState.reason} />
case 'render': case 'render':
return isChainMode ? ( return isChainMode ? (
<ChainProxyGroups <ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
) : ( ) : (
<NormalProxyGroups mode={mode} /> <NormalProxyGroups mode={mode} />
) )
+11 -6
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded, SortByAlphaRounded,
SortRounded, SortRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, IconButton, TextField, type SxProps } from '@mui/material' import { Box, IconButton, type SxProps } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network' import { isValidLatencyTestUrl } from '@/utils/network'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort' import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state' import type { HeadState } from './use-head-state'
@@ -72,7 +73,10 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204' 'http://cp.cloudflare.com/generate_204'
useEffect(() => { useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url]) }, [groupName, testUrl, defaultLatencyUrl, url])
return ( return (
@@ -97,7 +101,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) { if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl') showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return return
} }
@@ -188,7 +192,8 @@ export const ProxyHead = ({
)} )}
{textState === 'url' && ( {textState === 'url' && (
<TextField <ProxyTestUrlInput
name={groupName}
autoComplete="new-password" autoComplete="new-password"
autoFocus={autoFocus} autoFocus={autoFocus}
hiddenLabel hiddenLabel
@@ -197,7 +202,7 @@ export const ProxyHead = ({
size="small" size="small"
variant="outlined" variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')} placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onChange={(e) => onHeadState({ testUrl: e.target.value })} onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/> />
)} )}
@@ -0,0 +1,55 @@
import { TextField, type TextFieldProps } from '@mui/material'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
value: string
onCommit: (url: string) => void
}
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
const { profiles } = useProfiles()
return (
<DraftTestUrlInput
key={JSON.stringify([profiles?.current, name])}
name={name}
value={value}
{...props}
/>
)
}
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
const [draft, setDraft] = useState<string | null>(null)
const pendingRef = useRef<string | null>(null)
const commitOnUnmount = useEffectEvent(() => {
if (pendingRef.current !== null && pendingRef.current !== value)
onCommit(pendingRef.current)
})
useEffect(() => () => commitOnUnmount(), [])
return (
<TextField
{...props}
value={draft ?? value}
onChange={(event) => {
pendingRef.current = event.target.value
setDraft(event.target.value)
}}
onBlur={() => {
const next = pendingRef.current
pendingRef.current = null
setDraft(null)
if (next !== null && next !== value) onCommit(next)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
const input = event.target as HTMLInputElement
input.blur()
}
}}
/>
)
}
+12 -4
View File
@@ -13,11 +13,17 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
kind: 'node', kind: 'node',
ref: { kind: 'node', name, recordId: `${memberIndex}` }, ref: { kind: 'node', name, recordId: `${memberIndex}` },
node: { node: {
recordId: `${memberIndex}`,
history: [{ delay }], history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
history: [{ time: new Date(0).toISOString(), delay }],
},
},
source: { kind: 'provider', providerName: `${memberIndex}` }, source: { kind: 'provider', providerName: `${memberIndex}` },
}, },
}, },
}) as ResolvedMemberOccurrence }) as unknown as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
@@ -36,7 +42,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
for (const cached of [false, true]) { for (const cached of [false, true]) {
const group = `sort-${cached}` const group = `sort-${cached}`
if (cached) { if (cached) {
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) values.forEach((delay, i) =>
delayManager.setDelay(list[i].member, group, delay),
)
} }
for (const timeout of [10000, 20, 0, NaN]) { for (const timeout of [10000, 20, 0, NaN]) {
const expected = list const expected = list
@@ -66,7 +74,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
node(3, 25), node(3, 25),
] ]
const cachedOrder = [list[0], list[2], list[1], list[3]] const cachedOrder = [list[0], list[2], list[1], list[3]]
delayManager.setDelay('0', 'expiry', 1) delayManager.setDelay(list[0].member, 'expiry', 1)
const get = vi.spyOn(delayManager, 'getDelayFix') const get = vi.spyOn(delayManager, 'getDelayFix')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
expect(get).toHaveBeenCalledTimes(list.length) expect(get).toHaveBeenCalledTimes(list.length)
@@ -80,7 +88,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
list[3], list[3],
list[0], list[0],
]) ])
delayManager.setDelay('0', 'expiry', 2) delayManager.setDelay(list[0].member, 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear() get.mockClear()
filterSort([], 'expiry', '', 1) filterSort([], 'expiry', '', 1)
+60 -18
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useReducer } from 'react' import { useCallback, useEffect, useReducer } from 'react'
import { useProfiles } from '@/hooks/use-profiles' import { useProfiles } from '@/hooks/use-profiles'
import {
profileTestUrls,
commitProfileTestUrl,
} from '@/services/proxy-test-url'
import { ProxySortType } from './use-filter-sort' import { ProxySortType } from './use-filter-sort'
@@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = {
} }
type HeadStateAction = type HeadStateAction =
| { type: 'reset' } | { type: 'reset'; profile: string }
| { type: 'replace'; payload: Record<string, HeadState> } | { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'update'; groupName: string; patch: Partial<HeadState> } | { type: 'update'; groupName: string; patch: Partial<HeadState> }
| { type: 'urls'; urls: Record<string, string> }
interface LoadedHeadState {
profile: string | null
heads: Record<string, HeadState>
}
function headStateReducer( function headStateReducer(
state: Record<string, HeadState>, state: LoadedHeadState,
action: HeadStateAction, action: HeadStateAction,
): Record<string, HeadState> { ): LoadedHeadState {
switch (action.type) { switch (action.type) {
case 'reset': case 'reset':
return {} return { profile: action.profile, heads: {} }
case 'replace': case 'replace':
return action.payload return { profile: action.profile, heads: action.payload }
case 'urls': {
let heads = state.heads
for (const [name, testUrl] of Object.entries(action.urls)) {
if (heads[name]?.testUrl === testUrl) continue
heads = {
...heads,
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
}
}
return heads === state.heads ? state : { ...state, heads }
}
case 'update': { case 'update': {
const prev = state[action.groupName] || DEFAULT_STATE const prev = state.heads[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } } return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
} }
default: default:
return state return state
@@ -55,30 +82,42 @@ function headStateReducer(
} }
export function useHeadStateNew() { export function useHeadStateNew() {
const { profiles } = useProfiles() const { profiles, current: profile } = useProfiles()
const current = profiles?.current || '' const current = profiles?.current || ''
const [state, dispatch] = useReducer(headStateReducer, {}) const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
useEffect(() => { useEffect(() => {
const urls = profile ? profileTestUrls(profile) : {}
if (loadedProfile === current) {
dispatch({ type: 'urls', urls })
return
}
try { try {
const data = JSON.parse( const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!, localStorage.getItem(HEAD_STATE_KEY) || '{}',
) as HeadStateStorage ) as HeadStateStorage
const value = data[current] || {} const value = data?.[current] || {}
for (const [name, testUrl] of Object.entries(urls)) {
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
}
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
dispatch({ type: 'replace', payload: value }) dispatch({ type: 'replace', profile: current, payload: value })
} else { } else {
dispatch({ type: 'reset' }) dispatch({ type: 'reset', profile: current })
} }
} catch { } catch {
dispatch({ type: 'reset' }) dispatch({ type: 'reset', profile: current })
} }
}, [current]) }, [current, loadedProfile, profile])
useEffect(() => { useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
try { try {
const item = localStorage.getItem(HEAD_STATE_KEY) const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -94,13 +133,16 @@ export function useHeadStateNew() {
}) })
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [state, current]) }, [state, current, loadedProfile])
const setHeadState = useCallback( const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => { (groupName: string, obj: Partial<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj }) dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
}, },
[], [current],
) )
return [state, setHeadState] as const return [state, setHeadState] as const
+1
View File
@@ -116,6 +116,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false, mptcp: false,
smux: false, smux: false,
history: [], history: [],
extra: {},
members, members,
}) })
@@ -1,11 +1,20 @@
import { Button, Grid, Stack, TextField } from '@mui/material' import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react' import { useState, useRef, memo, useEffect } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds' import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service' import { errorDetail, showNotice } from '@/services/notice-service'
import { import {
@@ -34,6 +43,7 @@ export const BackupConfigViewer = memo(
const { t } = useTranslation() const { t } = useTranslation()
const { verge, mutateVerge } = useVerge() const { verge, mutateVerge } = useVerge()
const { webdav_url, webdav_username, webdav_password } = verge || {} const { webdav_url, webdav_username, webdav_password } = verge || {}
const [showPassword, setShowPassword] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null) const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null) const passwordRef = useRef<HTMLInputElement>(null)
const urlRef = useRef<HTMLInputElement>(null) const urlRef = useRef<HTMLInputElement>(null)
@@ -58,6 +68,10 @@ export const BackupConfigViewer = memo(
const webdavStatus = getWebdavStatus(webdavSignature) const webdavStatus = getWebdavStatus(webdavSignature)
const shouldAutoInit = webdavStatus !== 'failed' const shouldAutoInit = webdavStatus !== 'failed'
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev)
}
useEffect(() => { useEffect(() => {
if (!webdav_url || !webdav_username || !webdav_password) { if (!webdav_url || !webdav_username || !webdav_password) {
return return
@@ -201,7 +215,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}> <Grid size={{ xs: 6 }}>
<TextField <TextField
label={t('shared.labels.password')} label={t('shared.labels.password')}
type="password" type={showPassword ? 'text' : 'password'}
variant="outlined" variant="outlined"
size="small" size="small"
autoCorrect="off" autoCorrect="off"
@@ -209,6 +223,20 @@ export const BackupConfigViewer = memo(
spellCheck="false" spellCheck="false"
inputRef={passwordRef} inputRef={passwordRef}
{...register('password')} {...register('password')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/> />
</Grid> </Grid>
</Grid> </Grid>
@@ -48,7 +48,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
try { try {
const info = await checkUpdate() const info = await checkUpdate()
updateLastCheckTime() updateLastCheckTime()
if (!info?.available) { if (!info) {
showNotice.success( showNotice.success(
'settings.components.verge.advanced.notifications.latestVersion', 'settings.components.verge.advanced.notifications.latestVersion',
) )
+3 -9
View File
@@ -1,5 +1,5 @@
import { LanguageRounded } from '@mui/icons-material' import { LanguageRounded } from '@mui/icons-material'
import { alpha, Box, Divider, Menu, MenuItem, styled } from '@mui/material' import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -29,14 +29,8 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const onDelay = useCallback(async () => { const onDelay = useCallback(async () => {
setDelay(-2) setDelay(-2)
try { const result = await cmdTestDelay(url!)
const result = await cmdTestDelay(url!) setDelay(result)
setDelay(result)
} catch (err) {
// A failed test must not leave the item stuck in the loading state.
setDelay(-1)
showNotice.error(err)
}
}, [url]) }, [url])
const onEditTest = () => { const onEditTest = () => {
+8 -1
View File
@@ -7,8 +7,10 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base' import { BaseDialog } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import type { IVergeTestItem_Serialize } from '@/services/bindings'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
interface Props { interface Props {
onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void
} }
@@ -77,7 +79,12 @@ export const TestViewer = forwardRef<TestViewerRef, Props>(
if (openType === 'new') { if (openType === 'new') {
const uid = nanoid() const uid = nanoid()
const item = { ...form, uid } const item: IVergeTestItem_Serialize = {
uid,
name: form.name ?? null,
icon: form.icon ?? null,
url: form.url ?? null,
}
const newList = [...testList, item] const newList = [...testList, item]
await patchVerge({ test_list: newList }) await patchVerge({ test_list: newList })
onChange(uid) onChange(uid)
+11 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import type { ProfilePatch, ProfileSelection } from '@/services/bindings' import type { ProfilePatch, ProfileSelection } from '@/services/bindings'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { migrateProfileTestUrls } from '@/services/proxy-test-url'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
@@ -35,6 +36,10 @@ export const useProfiles = () => {
refetchInterval: false, refetchInterval: false,
}) })
useEffect(() => {
if (profiles) migrateProfileTestUrls(profiles)
}, [profiles])
const refetchRef = useRef(refetch) const refetchRef = useRef(refetch)
refetchRef.current = refetch refetchRef.current = refetch
const mutateProfiles = useCallback(async () => { const mutateProfiles = useCallback(async () => {
@@ -46,7 +51,6 @@ export const useProfiles = () => {
try { try {
const result = await mutate(() => patchProfilesConfig(value), { const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config', id: 'patch-profiles-config',
errorNotice: false,
onFulfilled: (outcome) => { onFulfilled: (outcome) => {
if (outcome.status === 'valid') { if (outcome.status === 'valid') {
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) => void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
@@ -56,8 +60,12 @@ export const useProfiles = () => {
}, },
}) })
if (!result.ok) {
// Backend Busy keeps local state untouched, as before.
return result.value
}
const outcome = result.value const outcome = result.value
if (outcome.status !== 'valid') { if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles() await mutateProfiles()
} }
return outcome return outcome
+12 -9
View File
@@ -34,7 +34,6 @@ export function useProxyDelayState(
groupName: string, groupName: string,
): UseProxyDelayState { ): UseProxyDelayState {
const name = member.ref.name const name = member.ref.name
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved' const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name) const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY) const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
@@ -43,18 +42,18 @@ export function useProxyDelayState(
useEffect(() => { useEffect(() => {
if (isPreset) return if (isPreset) return
delayManager.setListener(name, groupName, setDelayState) delayManager.setListener(member, groupName, setDelayState)
return () => { return () => {
delayManager.removeListener(name, groupName) delayManager.removeListener(member, groupName)
} }
}, [name, groupName, isPreset]) }, [member, groupName, isPreset])
const updateDelay = useCallback(() => { const updateDelay = useCallback(() => {
if (unresolved) { if (unresolved) {
setDelayState(INITIAL_DELAY) setDelayState(INITIAL_DELAY)
return return
} }
const cachedUpdate = delayManager.getDelayUpdate(name, groupName) const cachedUpdate = delayManager.getDelayUpdate(member, groupName)
if (cachedUpdate) { if (cachedUpdate) {
setDelayState({ ...cachedUpdate }) setDelayState({ ...cachedUpdate })
return return
@@ -67,7 +66,9 @@ export function useProxyDelayState(
} }
let updatedAt = 0 let updatedAt = 0
const history = details?.history const history =
delayManager.getHistory(member, groupName) ??
memberDetails(member)?.history
if (history && history.length > 0) { if (history && history.length > 0) {
const lastRecord = history[history.length - 1] const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time) const parsed = Date.parse(lastRecord.time)
@@ -77,16 +78,18 @@ export function useProxyDelayState(
} }
setDelayState({ delay: fallbackDelay, updatedAt }) setDelayState({ delay: fallbackDelay, updatedAt })
}, [details?.history, groupName, member, name, unresolved]) }, [groupName, member, unresolved])
useEffect(() => { useEffect(() => {
updateDelay() updateDelay()
}, [updateDelay]) return delayManager.addGroupListener(groupName, updateDelay)
}, [groupName, updateDelay])
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() }) setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(await delayManager.checkDelay(member, groupName, timeout)) await delayManager.checkDelay(member, groupName, timeout)
updateDelay()
}) })
return { return {
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Proxy-Kette konfigurieren", "header": "Proxy-Kette konfigurieren",
"warning": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.", "warning": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.",
"confirmEnable": "Verstanden, aktivieren",
"keepDisabled": "Deaktiviert lassen",
"empty": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.", "empty": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.",
"instruction": "Klicken Sie die Knoten nacheinander an, um sie zur Proxy-Kette hinzuzufügen", "instruction": "Klicken Sie die Knoten nacheinander an, um sie zur Proxy-Kette hinzuzufügen",
"minimumNodes": "Eine Proxy-Kette braucht mindestens 2 Knoten", "minimumNodes": "Eine Proxy-Kette braucht mindestens 2 Knoten",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "체인 프록시 구성", "header": "체인 프록시 구성",
"warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.", "warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"confirmEnable": "이해했습니다, 활성화",
"keepDisabled": "비활성화 유지",
"empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.", "empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요", "instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요",
"minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다", "minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Configuração do proxy em cadeia", "header": "Configuração do proxy em cadeia",
"warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", "warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.",
"confirmEnable": "Entendi, ativar",
"keepDisabled": "Manter desativado",
"empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", "empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.",
"instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy", "instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy",
"minimumNodes": "O proxy em cadeia requer pelo menos 2 nós", "minimumNodes": "O proxy em cadeia requer pelo menos 2 nós",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Цепочка прокси", "header": "Цепочка прокси",
"warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.", "warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"confirmEnable": "Понятно, включить",
"keepDisabled": "Оставить выключенным",
"empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.", "empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку", "instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку",
"minimumNodes": "Для цепочки требуется минимум 2 узла", "minimumNodes": "Для цепочки требуется минимум 2 узла",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Zincir Vekil Yapılandırması", "header": "Zincir Vekil Yapılandırması",
"warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.", "warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"confirmEnable": "Anladım, etkinleştir",
"keepDisabled": "Devre dışı kalsın",
"empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.", "empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın", "instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın",
"minimumNodes": "Zincir vekil en az 2 düğüm gerektirir", "minimumNodes": "Zincir vekil en az 2 düğüm gerektirir",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "代理链配置", "header": "代理链配置",
"warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭", "warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"confirmEnable": "我明白,开启",
"keepDisabled": "保持关闭",
"empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭", "empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"instruction": "顺序点击节点添加到代理链中", "instruction": "顺序点击节点添加到代理链中",
"minimumNodes": "链式代理至少需要 2 个节点", "minimumNodes": "链式代理至少需要 2 个节点",
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "鏈式代理設定", "header": "鏈式代理設定",
"warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉", "warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"confirmEnable": "我明白,開啟",
"keepDisabled": "保持關閉",
"empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉", "empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"instruction": "依序點擊節點新增到鏈式代理中", "instruction": "依序點擊節點新增到鏈式代理中",
"minimumNodes": "鏈式代理至少需要 2 個節點", "minimumNodes": "鏈式代理至少需要 2 個節點",
+24 -43
View File
@@ -1,10 +1,10 @@
import { LanOutlined, LanRounded, WarningRounded } from '@mui/icons-material' import { LanOutlined, LanRounded, WarningRounded } from '@mui/icons-material'
import { Box, Button, ButtonGroup, Typography } from '@mui/material' import { Box, Button, ButtonGroup } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer, useState } from 'react' import { useCallback, useEffect, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, BasePage, TooltipIcon } from '@/components/base' import { BasePage, TooltipIcon } from '@/components/base'
import { ProviderButton } from '@/components/proxy/provider-button' import { ProviderButton } from '@/components/proxy/provider-button'
import { ProxyGroups } from '@/components/proxy/proxy-groups' import { ProxyGroups } from '@/components/proxy/proxy-groups'
import { import {
@@ -38,7 +38,6 @@ const ProxyPage = () => {
return false return false
} }
}) })
const [chainConfirmOpen, setChainConfirmOpen] = useState(false)
const [chainConfigData, dispatchChainConfigData] = useReducer( const [chainConfigData, dispatchChainConfigData] = useReducer(
(_: string | null, action: string | null) => action, (_: string | null, action: string | null) => action,
@@ -69,34 +68,32 @@ const ProxyPage = () => {
} }
}) })
const applyChainMode = (enabled: boolean) => { const onToggleChainMode = useLockFn(async () => {
setIsChainMode(enabled) const newChainMode = !isChainMode
localStorage.setItem('proxy-chain-mode-enabled', enabled.toString())
}
const onExitChainMode = useLockFn(async () => { if (!newChainMode) {
// 退出链式代理模式时,清除链式代理配置 // 退出链式代理模式时,清除链式代理配置
try { try {
debugLog('Exiting chain mode, clearing chain configuration') debugLog('Exiting chain mode, clearing chain configuration')
const result = await mutate(() => updateProxyChainConfigInRuntime(null), { const result = await mutate(
id: 'update-proxy-chain-runtime', () => updateProxyChainConfigInRuntime(null),
errorNotice: false, {
}) id: 'update-proxy-chain-runtime',
if (!result.ok) return errorNotice: false,
debugLog('Chain configuration cleared successfully') },
} catch (error) { )
console.error('Failed to clear chain configuration:', error) if (!result.ok) return
showNotice.error(error) debugLog('Chain configuration cleared successfully')
return } catch (error) {
console.error('Failed to clear chain configuration:', error)
showNotice.error(error)
return
}
} }
applyChainMode(false) setIsChainMode(newChainMode)
localStorage.setItem('proxy-chain-mode-enabled', newChainMode.toString())
}) })
const onToggleChainMode = () => {
if (isChainMode) onExitChainMode().catch(() => {})
else setChainConfirmOpen(true)
}
// 当开启链式代理模式时,获取配置数据 // 当开启链式代理模式时,获取配置数据
useEffect(() => { useEffect(() => {
if (!isChainMode) { if (!isChainMode) {
@@ -205,23 +202,7 @@ const ProxyPage = () => {
mode={curMode ?? 'rule'} mode={curMode ?? 'rule'}
isChainMode={isChainMode} isChainMode={isChainMode}
chainConfigData={chainConfigData} chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/> />
<BaseDialog
open={chainConfirmOpen}
title={t('proxies.page.actions.toggleChain')}
okBtn={t('proxies.page.chain.confirmEnable')}
cancelBtn={t('proxies.page.chain.keepDisabled')}
contentSx={{ width: { xs: 320, sm: 420 } }}
onCancel={() => setChainConfirmOpen(false)}
onClose={() => setChainConfirmOpen(false)}
onOk={() => {
setChainConfirmOpen(false)
applyChainMode(true)
}}
>
<Typography variant="body2">{chainWarning}</Typography>
</BaseDialog>
</BasePage> </BasePage>
) )
} }
+117 -116
View File
@@ -53,16 +53,11 @@ export const commands = {
/** Reads the saved mode without depending on strict mihomo `/configs` deserialization. */ /** Reads the saved mode without depending on strict mihomo `/configs` deserialization. */
getClashMode: () => __TAURI_INVOKE<string | null>('get_clash_mode'), getClashMode: () => __TAURI_INVOKE<string | null>('get_clash_mode'),
changeClashCore: (clashCore: string) => changeClashCore: (clashCore: string) =>
__TAURI_INVOKE<{ __TAURI_INVOKE<CommandFailure_Serialize | null>('change_clash_core', {
/** Stable frontend identifier, when classified. */ clashCore,
code?: string | null }),
/** Human-readable diagnostic detail. */
detail: string
/** Requested operation when known by the command. */
operation?: FailedOperation | null
} | null>('change_clash_core', { clashCore }),
getRuntimeConfig: () => getRuntimeConfig: () =>
__TAURI_INVOKE<{ [key in string]: JsonValue } | null>('get_runtime_config'), __TAURI_INVOKE<YamlMapping | null>('get_runtime_config'),
getProxyView: () => __TAURI_INVOKE<ProxyViewV1_Serialize>('get_proxy_view'), getProxyView: () => __TAURI_INVOKE<ProxyViewV1_Serialize>('get_proxy_view'),
getRuntimeYaml: () => __TAURI_INVOKE<string>('get_runtime_yaml'), getRuntimeYaml: () => __TAURI_INVOKE<string>('get_runtime_yaml'),
getRuntimeLogs: () => __TAURI_INVOKE<RuntimeLogs>('get_runtime_logs'), getRuntimeLogs: () => __TAURI_INVOKE<RuntimeLogs>('get_runtime_logs'),
@@ -70,7 +65,7 @@ export const commands = {
__TAURI_INVOKE<string>('get_runtime_proxy_chain_config', { __TAURI_INVOKE<string>('get_runtime_proxy_chain_config', {
proxyChainExitNode, proxyChainExitNode,
}), }),
updateProxyChainConfigInRuntime: (proxyChainConfig: JsonValue | null) => updateProxyChainConfigInRuntime: (proxyChainConfig: YamlValue | null) =>
__TAURI_INVOKE<ValidationOutcome>('update_proxy_chain_config_in_runtime', { __TAURI_INVOKE<ValidationOutcome>('update_proxy_chain_config_in_runtime', {
proxyChainConfig, proxyChainConfig,
}), }),
@@ -100,18 +95,11 @@ export const commands = {
takeDnsOverrideNotice: () => takeDnsOverrideNotice: () =>
__TAURI_INVOKE<boolean>('take_dns_override_notice'), __TAURI_INVOKE<boolean>('take_dns_override_notice'),
takeServiceFallbackNotice: () => takeServiceFallbackNotice: () =>
__TAURI_INVOKE< __TAURI_INVOKE<ServiceFallbackNotice | null>(
| { kind: 'unavailable' } 'take_service_fallback_notice',
| { kind: 'coreRejected'; reason: string } ),
| { kind: 'notAutoStarted' }
| null
>('take_service_fallback_notice'),
getCoreStartupError: () => getCoreStartupError: () =>
__TAURI_INVOKE< __TAURI_INVOKE<CoreFailure | null>('get_core_startup_error'),
| { kind: 'startFailed'; detail: string }
| { kind: 'serviceCoreStopped'; detail: string }
| null
>('get_core_startup_error'),
takeServiceRepairNotice: () => takeServiceRepairNotice: () =>
__TAURI_INVOKE<boolean>('take_service_repair_notice'), __TAURI_INVOKE<boolean>('take_service_repair_notice'),
takeServiceOwnerNotice: () => takeServiceOwnerNotice: () =>
@@ -140,7 +128,7 @@ export const commands = {
__TAURI_INVOKE<NetworkInterfaceView[]>('get_network_interfaces_info'), __TAURI_INVOKE<NetworkInterfaceView[]>('get_network_interfaces_info'),
getProfiles: () => __TAURI_INVOKE<IProfiles_Serialize>('get_profiles'), getProfiles: () => __TAURI_INVOKE<IProfiles_Serialize>('get_profiles'),
enhanceProfiles: () => __TAURI_INVOKE<ValidationOutcome>('enhance_profiles'), enhanceProfiles: () => __TAURI_INVOKE<ValidationOutcome>('enhance_profiles'),
patchProfilesConfig: (profiles: ProfileSelection) => patchProfilesConfig: (profiles: ProfileSelection_Deserialize) =>
__TAURI_INVOKE<ValidationOutcome>('patch_profiles_config', { profiles }), __TAURI_INVOKE<ValidationOutcome>('patch_profiles_config', { profiles }),
viewProfile: (index: string) => viewProfile: (index: string) =>
__TAURI_INVOKE<null>('view_profile', { index }), __TAURI_INVOKE<null>('view_profile', { index }),
@@ -148,52 +136,12 @@ export const commands = {
__TAURI_INVOKE<null>('patch_profile', { index, profile }), __TAURI_INVOKE<null>('patch_profile', { index, profile }),
createProfile: (item: ProfileCreate_Deserialize, fileData: string | null) => createProfile: (item: ProfileCreate_Deserialize, fileData: string | null) =>
__TAURI_INVOKE<null>('create_profile', { item, fileData }), __TAURI_INVOKE<null>('create_profile', { item, fileData }),
importProfile: ( importProfile: (url: string, option: PrfOption_Deserialize | null) =>
url: string, __TAURI_INVOKE<null>('import_profile', { url, option }),
option: {
user_agent?: string | null
with_proxy?: boolean | null
self_proxy?: boolean | null
update_interval?: number | null
/**
* HTTP request timeout in seconds
* default is 60 seconds
*/
timeout_seconds?: number | null
/** default is `false` */
danger_accept_invalid_certs?: boolean | null
allow_auto_update?: boolean | null
merge?: string | null
script?: string | null
rules?: string | null
proxies?: string | null
groups?: string | null
} | null,
) => __TAURI_INVOKE<null>('import_profile', { url, option }),
reorderProfile: (activeId: string, overId: string) => reorderProfile: (activeId: string, overId: string) =>
__TAURI_INVOKE<null>('reorder_profile', { activeId, overId }), __TAURI_INVOKE<null>('reorder_profile', { activeId, overId }),
updateProfile: ( updateProfile: (index: string, option: PrfOption_Deserialize | null) =>
index: string, __TAURI_INVOKE<null>('update_profile', { index, option }),
option: {
user_agent?: string | null
with_proxy?: boolean | null
self_proxy?: boolean | null
update_interval?: number | null
/**
* HTTP request timeout in seconds
* default is 60 seconds
*/
timeout_seconds?: number | null
/** default is `false` */
danger_accept_invalid_certs?: boolean | null
allow_auto_update?: boolean | null
merge?: string | null
script?: string | null
rules?: string | null
proxies?: string | null
groups?: string | null
} | null,
) => __TAURI_INVOKE<null>('update_profile', { index, option }),
deleteProfile: (index: string) => deleteProfile: (index: string) =>
__TAURI_INVOKE<null>('delete_profile', { index }), __TAURI_INVOKE<null>('delete_profile', { index }),
readProfileFile: (index: string) => readProfileFile: (index: string) =>
@@ -263,11 +211,11 @@ export type CommandFailure_Deserialize = {
/** Structured command failure returned to the frontend. */ /** Structured command failure returned to the frontend. */
export type CommandFailure_Serialize = { export type CommandFailure_Serialize = {
/** Stable frontend identifier, when classified. */ /** Stable frontend identifier, when classified. */
code?: string | null code?: string
/** Human-readable diagnostic detail. */ /** Human-readable diagnostic detail. */
detail: string detail: string
/** Requested operation when known by the command. */ /** Requested operation when known by the command. */
operation?: FailedOperation | null operation?: FailedOperation
} }
/** Why the Core is not running, kept until a Core starts again. */ /** Why the Core is not running, kept until a Core starts again. */
@@ -330,14 +278,27 @@ export type IProfiles_Serialize = {
/** ### `verge.yaml` schema */ /** ### `verge.yaml` schema */
export type IVerge = IVerge_Serialize | IVerge_Deserialize export type IVerge = IVerge_Serialize | IVerge_Deserialize
export type IVergeTestItem = { export type IVergeTestItem =
| IVergeTestItem_Serialize
| IVergeTestItem_Deserialize
export type IVergeTestItem_Deserialize = {
uid?: string | null uid?: string | null
name?: string | null name?: string | null
icon?: string | null icon?: string | null
url?: string | null url?: string | null
} }
export type IVergeTheme = { export type IVergeTestItem_Serialize = {
uid: string | null
name: string | null
icon: string | null
url: string | null
}
export type IVergeTheme = IVergeTheme_Serialize | IVergeTheme_Deserialize
export type IVergeTheme_Deserialize = {
primary_color?: string | null primary_color?: string | null
secondary_color?: string | null secondary_color?: string | null
primary_text?: string | null primary_text?: string | null
@@ -350,6 +311,19 @@ export type IVergeTheme = {
css_injection?: string | null css_injection?: string | null
} }
export type IVergeTheme_Serialize = {
primary_color: string | null
secondary_color: string | null
primary_text: string | null
secondary_text: string | null
info_color: string | null
error_color: string | null
warning_color: string | null
success_color: string | null
font_family: string | null
css_injection: string | null
}
/** ### `verge.yaml` schema */ /** ### `verge.yaml` schema */
export type IVerge_Deserialize = { export type IVerge_Deserialize = {
/** silent | error | warn | info | debug | trace */ /** silent | error | warn | info | debug | trace */
@@ -392,7 +366,7 @@ export type IVerge_Deserialize = {
proxy_auto_config?: boolean | null proxy_auto_config?: boolean | null
pac_file_content?: string | null pac_file_content?: string | null
proxy_host?: string | null proxy_host?: string | null
theme_setting?: IVergeTheme | null theme_setting?: IVergeTheme_Deserialize | null
web_ui_list?: string[] | null web_ui_list?: string[] | null
clash_core?: string | null clash_core?: string | null
/** format: {func},{key} */ /** format: {func},{key} */
@@ -408,7 +382,7 @@ export type IVerge_Deserialize = {
auto_delay_detection_interval_minutes?: number | null auto_delay_detection_interval_minutes?: number | null
enable_builtin_enhanced?: boolean | null enable_builtin_enhanced?: boolean | null
proxy_layout_column?: number | null proxy_layout_column?: number | null
test_list?: IVergeTestItem[] | null test_list?: IVergeTestItem_Deserialize[] | null
/** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */ /** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */
auto_log_clean?: number | null auto_log_clean?: number | null
enable_auto_backup_schedule?: boolean | null enable_auto_backup_schedule?: boolean | null
@@ -457,13 +431,13 @@ export type IVerge_Serialize = {
traffic_graph: boolean | null traffic_graph: boolean | null
/** show memory info (only for Clash Meta) */ /** show memory info (only for Clash Meta) */
enable_memory_usage: boolean | null enable_memory_usage: boolean | null
enable_group_icon?: boolean | null enable_group_icon?: boolean
pause_render_traffic_stats_on_blur: boolean | null pause_render_traffic_stats_on_blur: boolean | null
common_tray_icon?: boolean | null common_tray_icon?: boolean
menu_icon?: string | null menu_icon?: string
menu_order?: string[] | null menu_order?: string[]
proxy_group_tools_position?: string | null proxy_group_tools_position?: string
notice_position?: string | null notice_position?: string
collapse_navbar: boolean | null collapse_navbar: boolean | null
sysproxy_tray_icon: boolean | null sysproxy_tray_icon: boolean | null
tun_tray_icon: boolean | null tun_tray_icon: boolean | null
@@ -482,11 +456,11 @@ export type IVerge_Serialize = {
proxy_auto_config: boolean | null proxy_auto_config: boolean | null
pac_file_content: string | null pac_file_content: string | null
proxy_host: string | null proxy_host: string | null
theme_setting: IVergeTheme | null theme_setting: IVergeTheme_Serialize | null
web_ui_list: string[] | null web_ui_list: string[] | null
clash_core?: string | null clash_core?: string
/** format: {func},{key} */ /** format: {func},{key} */
hotkeys?: string[] | null hotkeys?: string[]
enable_global_hotkey: boolean | null enable_global_hotkey: boolean | null
home_cards: JsonValue | null home_cards: JsonValue | null
auto_close_connection: boolean | null auto_close_connection: boolean | null
@@ -498,7 +472,7 @@ export type IVerge_Serialize = {
auto_delay_detection_interval_minutes: number | null auto_delay_detection_interval_minutes: number | null
enable_builtin_enhanced: boolean | null enable_builtin_enhanced: boolean | null
proxy_layout_column: number | null proxy_layout_column: number | null
test_list: IVergeTestItem[] | null test_list: IVergeTestItem_Serialize[] | null
/** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */ /** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */
auto_log_clean: number | null auto_log_clean: number | null
enable_auto_backup_schedule: boolean | null enable_auto_backup_schedule: boolean | null
@@ -510,10 +484,10 @@ export type IVerge_Serialize = {
verge_socks_enabled: boolean | null verge_socks_enabled: boolean | null
verge_port: number | null verge_port: number | null
verge_http_enabled: boolean | null verge_http_enabled: boolean | null
webdav_url?: string | null webdav_url?: string
webdav_username?: string | null webdav_username?: string
webdav_password?: string | null webdav_password?: string
tray_proxy_groups_display_mode?: string | null tray_proxy_groups_display_mode?: string
tray_inline_outbound_modes: boolean | null tray_inline_outbound_modes: boolean | null
enable_auto_light_weight_mode: boolean | null enable_auto_light_weight_mode: boolean | null
/** 自动进入轻量模式的延迟(分钟) */ /** 自动进入轻量模式的延迟(分钟) */
@@ -658,7 +632,8 @@ export type PrfItem_Deserialize = {
file?: string | null file?: string | null
desc?: string | null desc?: string | null
url?: string | null url?: string | null
selected?: PrfSelected[] | null selected?: PrfSelected_Deserialize[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null extra?: PrfExtra | null
updated?: number | null updated?: number | null
option?: PrfOption_Deserialize | null option?: PrfOption_Deserialize | null
@@ -671,13 +646,14 @@ export type PrfItem_Serialize = {
type: string | null type: string | null
name: string | null name: string | null
file: string | null file: string | null
desc?: string | null desc?: string
url?: string | null url?: string
selected?: PrfSelected[] | null selected?: PrfSelected_Serialize[]
extra?: PrfExtra | null latency_test_urls?: { [key in string]: string }
extra?: PrfExtra
updated: number | null updated: number | null
option?: PrfOption_Serialize | null option?: PrfOption_Serialize
home?: string | null home?: string
} }
export type PrfOption = PrfOption_Serialize | PrfOption_Deserialize export type PrfOption = PrfOption_Serialize | PrfOption_Deserialize
@@ -703,18 +679,18 @@ export type PrfOption_Deserialize = {
} }
export type PrfOption_Serialize = { export type PrfOption_Serialize = {
user_agent?: string | null user_agent?: string
with_proxy?: boolean | null with_proxy?: boolean
self_proxy?: boolean | null self_proxy?: boolean
update_interval?: number | null update_interval?: number
/** /**
* HTTP request timeout in seconds * HTTP request timeout in seconds
* default is 60 seconds * default is 60 seconds
*/ */
timeout_seconds?: number | null timeout_seconds?: number
/** default is `false` */ /** default is `false` */
danger_accept_invalid_certs?: boolean | null danger_accept_invalid_certs?: boolean
allow_auto_update?: boolean | null allow_auto_update?: boolean
merge: string | null merge: string | null
script: string | null script: string | null
rules: string | null rules: string | null
@@ -722,11 +698,18 @@ export type PrfOption_Serialize = {
groups: string | null groups: string | null
} }
export type PrfSelected = { export type PrfSelected = PrfSelected_Serialize | PrfSelected_Deserialize
export type PrfSelected_Deserialize = {
name?: string | null name?: string | null
now?: string | null now?: string | null
} }
export type PrfSelected_Serialize = {
name: string | null
now: string | null
}
export type ProfileCreate = ProfileCreate_Serialize | ProfileCreate_Deserialize export type ProfileCreate = ProfileCreate_Serialize | ProfileCreate_Deserialize
export type ProfileCreate_Deserialize = { export type ProfileCreate_Deserialize = {
@@ -756,7 +739,7 @@ export type ProfileDnsSettings_Deserialize = {
export type ProfileDnsSettings_Serialize = { export type ProfileDnsSettings_Serialize = {
enabled: boolean enabled: boolean
confirmation?: string | null confirmation?: string
} }
export type ProfilePatch = ProfilePatch_Serialize | ProfilePatch_Deserialize export type ProfilePatch = ProfilePatch_Serialize | ProfilePatch_Deserialize
@@ -767,7 +750,8 @@ export type ProfilePatch_Deserialize = {
desc?: string | null desc?: string | null
file?: string | null file?: string | null
url?: string | null url?: string | null
selected?: PrfSelected[] | null selected?: PrfSelected_Deserialize[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null extra?: PrfExtra | null
updated?: number | null updated?: number | null
option?: PrfOption_Deserialize | null option?: PrfOption_Deserialize | null
@@ -779,16 +763,25 @@ export type ProfilePatch_Serialize = {
desc: string | null desc: string | null
file: string | null file: string | null
url: string | null url: string | null
selected: PrfSelected[] | null selected: PrfSelected_Serialize[] | null
latency_test_urls: { [key in string]: string } | null
extra: PrfExtra | null extra: PrfExtra | null
updated: number | null updated: number | null
option: PrfOption_Serialize | null option: PrfOption_Serialize | null
} }
export type ProfileSelection = { export type ProfileSelection =
| ProfileSelection_Serialize
| ProfileSelection_Deserialize
export type ProfileSelection_Deserialize = {
current?: string | null current?: string | null
} }
export type ProfileSelection_Serialize = {
current: string | null
}
export type ProfileUpdatePayload = { export type ProfileUpdatePayload = {
uid: string uid: string
} }
@@ -814,21 +807,27 @@ export type ProxyGroupView_Deserialize = {
icon: string | null icon: string | null
testUrl: string | null testUrl: string | null
history: DelayHistory[] history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[] members: ProxyMemberRef[]
} & ProxyCapabilities } & ProxyCapabilities
export type ProxyGroupView_Serialize = { export type ProxyGroupView_Serialize = {
name: string name: string
type: string type: string
now?: string | null now?: string
fixed?: string | null fixed?: string
hidden?: boolean | null hidden?: boolean
icon?: string | null icon?: string
testUrl?: string | null testUrl?: string
history: DelayHistory[] history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[] members: ProxyMemberRef[]
} & ProxyCapabilities } & ProxyCapabilities
export type ProxyLatencyHistory = {
history: DelayHistory[]
}
export type ProxyMemberRef = export type ProxyMemberRef =
| { kind: 'group'; name: string } | { kind: 'group'; name: string }
| { kind: 'node'; name: string; recordId: string } | { kind: 'node'; name: string; recordId: string }
@@ -850,6 +849,7 @@ export type ProxyNodeView_Deserialize = {
name: string name: string
type: string type: string
history: DelayHistory[] history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id: string | null id: string | null
hidden: boolean | null hidden: boolean | null
icon: string | null icon: string | null
@@ -862,10 +862,11 @@ export type ProxyNodeView_Serialize = {
name: string name: string
type: string type: string
history: DelayHistory[] history: DelayHistory[]
id?: string | null extra: { [key in string]: ProxyLatencyHistory }
hidden?: boolean | null id?: string
icon?: string | null hidden?: boolean
testUrl?: string | null icon?: string
testUrl?: string
source: ProxyNodeSource source: ProxyNodeSource
} & ProxyCapabilities } & ProxyCapabilities
@@ -894,8 +895,8 @@ export type ProxyProviderView_Deserialize = {
export type ProxyProviderView_Serialize = { export type ProxyProviderView_Serialize = {
name: string name: string
vehicleType: ProxyProviderVehicleType vehicleType: ProxyProviderVehicleType
updatedAt?: string | null updatedAt?: string
subscriptionInfo?: ProxySubscriptionInfo | null subscriptionInfo?: ProxySubscriptionInfo
proxyRecordIds: string[] proxyRecordIds: string[]
} }
+13 -2
View File
@@ -91,8 +91,18 @@ export async function deleteProfile(index: string) {
} }
export async function patchProfile(index: string, profile: ProfilePatch) { export async function patchProfile(index: string, profile: ProfilePatch) {
const { type, name, desc, file, url, selected, extra, updated, option } = const {
profile type,
name,
desc,
file,
url,
selected,
latency_test_urls,
extra,
updated,
option,
} = profile
return commands.patchProfile(index, { return commands.patchProfile(index, {
type, type,
name, name,
@@ -100,6 +110,7 @@ export async function patchProfile(index: string, profile: ProfilePatch) {
file, file,
url, url,
selected, selected,
latency_test_urls,
extra, extra,
updated, updated,
option, option,

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