Compare commits

..
1 Commits
Author SHA1 Message Date
renovate[bot] bf65f9632d chore(deps): update npm dependencies 2026-10-13 12:35:01 +00:00
94 changed files with 2341 additions and 1158 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.22.1",
"pnpmVersion": "12.14.0"
"version": "24.21.0",
"pnpmVersion": "12.11.2"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1",
"version": "1.99.0",
"profile": "default"
},
"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: |
## 在提交问题之前,请确认以下事项:
提交前可参考 [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)
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
@@ -21,8 +19,6 @@ body:
## 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)
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
@@ -9,16 +9,12 @@ body:
attributes:
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) 确认软件不存在类似的功能
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
## 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
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
+1 -7
View File
@@ -1,12 +1,6 @@
## Problem
What breaks or what is missing. Product bug fixes and features require a
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.
What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
## Approach
+10 -10
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -185,7 +185,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- name: Pnpm Cache
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -282,7 +282,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- name: Pnpm Cache
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -426,7 +426,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- name: Pnpm Cache
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -524,7 +524,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.1
uses: dtolnay/rust-toolchain@1.99.0
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- name: Pnpm Cache
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- name: Restore pnpm cache
@@ -138,7 +138,7 @@ jobs:
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.22.1'
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -230,7 +230,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -466,7 +466,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target
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
echo "Rust target ${{ matrix.target }} installed."
@@ -547,7 +547,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -573,7 +573,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -638,7 +638,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
toolchain: '1.99.0'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.22.1
pnpm 12.14.0
rust 1.99.1
nodejs 24.21.0
pnpm 12.11.2
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
[`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;
self-scoped maintenance work does not. Follow the PR template for linkage and
revert references. If a required issue is missing, ask maintainers to open or
approve one before implementing. For security-sensitive fixes that cannot be
tracked publicly, follow maintainer-approved private tracking.
1. **Issue first.** Non-trivial changes require a pre-existing issue describing
the problem. If none exists, ask the maintainers to open or approve one
before implementing.
2. **Scope discipline.** Every changed file must be justifiable from the linked
issue or the stated PR scope. No drive-by refactors, renames, formatting
churn, or dependency bumps unrelated to the work.
issue. No drive-by refactors, renames, formatting churn, or dependency bumps
unrelated to the problem being fixed.
3. **Author accountability.** AI assistance is welcome, but the contributor owns
the result: understand the change, describe the problem and approach in your
own words, and verify the change against the reported behavior before
submitting.
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
speculative defensive code unless the linked issue or stated task demands
them. When a test is genuinely necessary — it reproduces the reported
regression or guards behavior whose breakage would otherwise go unnoticed —
keep it minimal and state in the PR body why it is needed. Bulk test files
and defensive programming for hypothetical failure modes are PR bloat, not
rigor.
speculative defensive code unless the linked issue demands them. When a test
is genuinely necessary — it reproduces the reported regression or guards
behavior whose breakage would otherwise go unnoticed — keep it minimal and
state in the PR body why it is needed. Bulk test files and defensive
programming for hypothetical failure modes are PR bloat, not rigor.
5. **Comments state constraints, not narration.** Write a comment only for a
non-obvious constraint the code cannot express; never restate what the code
does.
@@ -47,12 +44,13 @@ the ownership evidence the AI-slop review evaluates (see
real evidence instead: reproduction steps, failure output, targeted tests.
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
density. Do not introduce new dependencies or restructure working code unless
the linked issue or stated task demands it.
9. **Disclose AI automation.** When an agent creates or co-creates a PR or Issue,
append a final body line with the model and effort used (e.g.
`Assisted by: GPT-5.6 High`). Effort may be omitted when the runtime does not
report it. Human contributors are not asked to disclose AI assistance.
Disclosure is transparency only and carries no positive or negative weight.
the issue demands it.
9. **Disclose AI automation.** When an agent produces or co-produces a change,
append a footer line to the PR body with the model and effort used (e.g.
`Assisted by: GPT-5.6 High`). The PR template intentionally omits this line —
the agent adds it itself, humans are not asked to declare anything. Effort may
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
[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
@@ -61,10 +59,8 @@ the ownership evidence the AI-slop review evaluates (see
[`template/Changelog.md`](template/Changelog.md): one line per
user-visible change, no internals.
## Issue and PR Workflows
## Pull Request Shape
When drafting, triaging, splitting, or implementing from a community issue, use
the conditional checklist in [`.github/ISSUE_GUIDELINES.md`](.github/ISSUE_GUIDELINES.md).
Do not make GitHub issue changes without explicit authorization. For PRs, follow
the directly loaded [PR template](.github/PULL_REQUEST_TEMPLATE.md); do not
duplicate its prompts here.
Describe three things, briefly: the problem (with issue link), why this approach
solves it, and what changed. See
[`PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
Generated
+191 -107
View File
@@ -1119,7 +1119,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"minisign-verify 0.3.0",
"nanoid",
"network-interface",
"objc2",
@@ -1138,7 +1138,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
"sha2 0.11.1",
"sha2 0.11.0",
"smartstring",
"socket2",
"specta",
@@ -1175,7 +1175,7 @@ dependencies = [
"windows-service",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip 9.0.4",
"zip 9.0.1",
]
[[package]]
@@ -1247,7 +1247,7 @@ dependencies = [
"once_cell",
"serde",
"serde_json",
"sha2 0.11.1",
"sha2 0.11.0",
"strum",
"strum_macros",
"sysproxy",
@@ -1462,12 +1462,9 @@ dependencies = [
[[package]]
name = "constant_time_eq"
version = "0.6.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9"
dependencies = [
"rustversion",
]
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "cookie"
@@ -3954,9 +3951,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.191"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
@@ -4051,11 +4048,11 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.22.0"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
dependencies = [
"sha2 0.11.1",
"sha2 0.11.0",
]
[[package]]
@@ -4170,6 +4167,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "minisign-verify"
version = "0.3.0"
@@ -5340,9 +5343,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.5.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]]
name = "ppv-lite86"
@@ -5993,13 +5996,12 @@ dependencies = [
[[package]]
name = "rust-ini"
version = "0.21.1"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
"trim-in-place",
]
[[package]]
@@ -6065,9 +6067,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.46"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"aws-lc-rs",
"log",
@@ -6408,9 +6410,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.154"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -6595,9 +6597,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -6620,6 +6622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
@@ -6639,6 +6642,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook 0.3.18",
]
[[package]]
name = "signal-hook"
version = "0.1.17"
@@ -6649,6 +6663,16 @@ dependencies = [
"signal-hook-registry",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -6854,12 +6878,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "static_vcruntime"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -6919,9 +6937,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.9"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9"
checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e"
dependencies = [
"base64 0.21.7",
"serde",
@@ -7168,9 +7186,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.12.4"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
dependencies = [
"anyhow",
"bytes",
@@ -7216,16 +7234,16 @@ dependencies = [
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
"webview2-com 0.39.1",
"window-vibrancy",
"windows 0.62.2",
]
[[package]]
name = "tauri-build"
version = "2.7.3"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
dependencies = [
"anyhow",
"cargo_toml",
@@ -7237,8 +7255,6 @@ dependencies = [
"semver 1.0.28",
"serde",
"serde_json",
"static_vcruntime",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -7246,11 +7262,11 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.7.3"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f"
checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80"
dependencies = [
"base64 0.23.1",
"base64 0.22.1",
"brotli",
"ico",
"json-patch",
@@ -7273,9 +7289,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.7.3"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f"
checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -7331,9 +7347,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.5.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81"
checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a"
dependencies = [
"arboard",
"log",
@@ -7346,9 +7362,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.6.2"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4"
checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736"
dependencies = [
"dunce",
"plist",
@@ -7394,9 +7410,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.3"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b"
checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
dependencies = [
"log",
"raw-window-handle",
@@ -7412,9 +7428,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.7.1"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb"
dependencies = [
"anyhow",
"dunce",
@@ -7436,9 +7452,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.4.1"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d"
checksum = "0bd2e1f725891a1613af8f25bf918ba3357654b0fd3f2d794c0ce76cbdd92ed2"
dependencies = [
"global-hotkey",
"log",
@@ -7451,9 +7467,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.8.3"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c57e3451553f95a1db3bfed6a358a77ce5080b6399a242c6eadac113f97abd2"
checksum = "93e9d6b3319e101bc1be27cfa0fb4054d7784a12509c67ead0033b82180e5ed1"
dependencies = [
"bytes",
"cookie_store",
@@ -7470,13 +7486,13 @@ dependencies = [
"thiserror 2.0.20",
"tokio",
"url",
"urlpattern",
"urlpattern 0.3.0",
]
[[package]]
name = "tauri-plugin-mihomo"
version = "0.6.0"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?rev=7a03d16f4d39b1bb885b7babe66b9e153e34b615#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
dependencies = [
"arc-swap",
"clashmap",
@@ -7500,9 +7516,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-notification"
version = "2.7.0"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b"
checksum = "533770889abb60a0407d5a32096182f0e6463f5a3dfaa675be94580aa14070e1"
dependencies = [
"log",
"notify-rust",
@@ -7515,14 +7531,13 @@ dependencies = [
"thiserror 2.0.20",
"time",
"url",
"zbus",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.7.2"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345"
dependencies = [
"dunce",
"glob",
@@ -7552,12 +7567,11 @@ dependencies = [
[[package]]
name = "tauri-plugin-shell"
version = "2.5.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521"
checksum = "094bdbc47e5efe36544ba21b3a53ab5c724150236cf55e6c9be46191deb6eab6"
dependencies = [
"encoding_rs",
"libc",
"log",
"open",
"os_pipe",
@@ -7570,14 +7584,13 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.20",
"tokio",
"windows-sys 0.60.2",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.14.0"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec"
checksum = "3cb0b2ea3e85ca287990d3859a29cc5cb18024240fd78f62e027fb7963670f18"
dependencies = [
"base64 0.22.1",
"dirs 7.0.0",
@@ -7585,9 +7598,8 @@ dependencies = [
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"minisign-verify 0.2.5",
"osakit",
"percent-encoding",
"reqwest 0.13.5",
@@ -7609,9 +7621,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-wdio-webdriver"
version = "1.5.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f76676d510d271d80abcf8b0552987b5b4f0eae5e3f906e9b5660d43c97c62e"
checksum = "52c5e97428174a52b7f4357bbc4535fcce52c6a06c8ee7a379502f7cda1b6045"
dependencies = [
"async-trait",
"axum",
@@ -7635,9 +7647,9 @@ dependencies = [
"tracing",
"uuid",
"webkit2gtk",
"webview2-com",
"windows 0.62.2",
"windows-core 0.62.2",
"webview2-com 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
@@ -7657,9 +7669,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.12.2"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9"
checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
dependencies = [
"cookie",
"dpi",
@@ -7676,15 +7688,15 @@ dependencies = [
"thiserror 2.0.20",
"url",
"webkit2gtk",
"webview2-com",
"webview2-com 0.39.1",
"windows 0.62.2",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.12.2"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [
"gtk",
"http",
@@ -7702,7 +7714,7 @@ dependencies = [
"tracing",
"url",
"webkit2gtk",
"webview2-com",
"webview2-com 0.39.1",
"windows 0.62.2",
"wry",
]
@@ -7740,9 +7752,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.10.2"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3"
checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
dependencies = [
"anyhow",
"brotli",
@@ -7771,7 +7783,7 @@ dependencies = [
"thiserror 2.0.20",
"toml 1.1.6+spec-1.1.0",
"url",
"urlpattern",
"urlpattern 0.6.0",
"uuid",
"walkdir",
]
@@ -7890,7 +7902,7 @@ dependencies = [
"regex",
"semver 0.11.0",
"sha2 0.9.9",
"signal-hook",
"signal-hook 0.1.17",
"terminfo",
"termios",
"thiserror 1.0.69",
@@ -8061,9 +8073,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.3"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
@@ -8123,9 +8135,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.20"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
@@ -8488,12 +8500,6 @@ dependencies = [
"petgraph",
]
[[package]]
name = "trim-in-place"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daa60874b45b168f67f3f9ba1ee821ad77144d6fa19cef1cd1874a7450a948da"
[[package]]
name = "triomphe"
version = "0.1.16"
@@ -8586,6 +8592,47 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-ucd-ident"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -8645,6 +8692,18 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlpattern"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d"
dependencies = [
"regex",
"serde",
"unic-ucd-ident",
"url",
]
[[package]]
name = "urlpattern"
version = "0.6.0"
@@ -9036,6 +9095,20 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
]
[[package]]
name = "webview2-com"
version = "0.39.1"
@@ -9043,7 +9116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"webview2-com-sys 0.39.1",
"windows 0.62.2",
"windows-core 0.62.2",
]
@@ -9059,6 +9132,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "webview2-com-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "webview2-com-sys"
version = "0.39.1"
@@ -9798,7 +9882,7 @@ dependencies = [
"url",
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"webview2-com 0.39.1",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-version",
@@ -10066,9 +10150,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.4"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [
"aes",
"bzip2",
@@ -10118,27 +10202,27 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.14.3"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "8.2.0"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.2+zstd.1.5.7"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
+4 -4
View File
@@ -61,13 +61,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "2.12.4" }
tauri-plugin-clipboard-manager = "2.5.0"
tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.4.1"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.3", features = [
tokio = { version = "1.53.2", features = [
"rt-multi-thread",
"macros",
"time",
@@ -88,7 +88,7 @@ rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.154" }
serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
socket2 = "0.6.5"
+2 -5
View File
@@ -3,10 +3,8 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复「首页」网站测速失败后卡片一直显示测试中的问题
**🐧 Linux**
@@ -23,7 +21,6 @@
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
+2
View File
@@ -70,6 +70,8 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
+2
View File
@@ -70,6 +70,8 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
+2
View File
@@ -70,6 +70,8 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global
profiles: Perfis
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
+31 -23
View File
@@ -1,15 +1,3 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
@@ -17,13 +5,20 @@ use std::sync::{
use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
/// Serializes signal-triggered cleanups: a signal arriving while a quit
/// attempt is still in flight is dropped instead of overlapping it.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
@@ -41,18 +36,17 @@ impl ShutdownLatch {
.is_ok()
}
fn finish(&self) {
self.is_cleaning_up.store(false, Ordering::Release);
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
}
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -67,22 +61,36 @@ where
}
});
#[cfg(unix)]
unix::register(f);
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::ShutdownLatch;
use super::{ShutdownLatch, ShutdownOutcome};
#[test]
fn finished_attempt_releases_latch_for_the_next_signal() {
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish();
latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+3 -4
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -60,8 +60,7 @@ where
}
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish();
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
+83
View File
@@ -0,0 +1,83 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -15,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.191"
libc = "0.2.190"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
+12 -12
View File
@@ -54,13 +54,13 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.4",
"@tauri-apps/plugin-clipboard-manager": "2.5.0",
"@tauri-apps/plugin-dialog": "2.8.3",
"@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2",
"@tauri-apps/plugin-updater": "2.14.0",
"@tauri-apps/api": "2.12.3",
"@tauri-apps/plugin-clipboard-manager": "2.4.2",
"@tauri-apps/plugin-dialog": "2.8.2",
"@tauri-apps/plugin-fs": "2.7.0",
"@tauri-apps/plugin-http": "2.8.2",
"@tauri-apps/plugin-opener": "2.7.1",
"@tauri-apps/plugin-updater": "2.13.3",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
@@ -75,11 +75,11 @@
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.19",
"react-i18next": "17.0.16",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
@@ -95,7 +95,7 @@
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3",
"@tauri-apps/cli": "2.12.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
@@ -146,5 +146,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.14.0"
"packageManager": "pnpm@12.11.2"
}
+261 -261
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -45,17 +45,6 @@
"matchManagers": ["npm"],
"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",
"matchManagers": ["github-actions"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.1"
channel = "1.99.0"
components = ["rustfmt", "clippy"]
+18 -21
View File
@@ -29,12 +29,13 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.7.3", features = [] }
tauri-build = { version = "2.7.1", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -72,55 +73,51 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.5.0"
tauri-plugin-opener = "2.7.2"
tauri-plugin-dialog = "2.8.3"
tauri-plugin-fs = "2.7.1"
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.4"
zip = "9.0.1"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11.1"
sha2 = "0.11"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.7.0"
tauri-plugin-notification = "2.5.1"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.3"
tauri-plugin-http = "2.8.0"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
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"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.191"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
libc = "0.2.190"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
@@ -153,8 +150,8 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.0"
tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.13.1"
[dev-dependencies]
criterion = { workspace = true }
+7 -6
View File
@@ -282,9 +282,7 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
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)
}
@@ -313,6 +311,9 @@ async fn perform_config_update(
current_value: Option<&String>,
current_profile: Option<&String>,
) -> CmdResult<ValidationOutcome> {
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result {
@@ -372,6 +373,8 @@ pub struct ProfilePatch {
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
@@ -386,6 +389,7 @@ impl From<ProfilePatch> for PrfItem {
file: patch.file,
url: patch.url,
selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
@@ -405,9 +409,6 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(ValidationOutcome::Busy);
}
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles {
+14 -2
View File
@@ -108,20 +108,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
#[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), 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()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers: providers.ok(),
providers,
}))
}
+6
View File
@@ -53,6 +53,10 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")]
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")]
pub extra: Option<PrfExtra>,
@@ -229,6 +233,7 @@ impl PrfItem {
desc: Some(desc),
file: Some(file),
url: None,
latency_test_urls: None,
selected: None,
extra: None,
option: Some(PrfOption {
@@ -408,6 +413,7 @@ impl PrfItem {
desc: desc.cloned(),
file: Some(file),
url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None,
extra,
option: Some(PrfOption {
+1
View File
@@ -250,6 +250,7 @@ impl IProfiles {
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
+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::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
@@ -57,6 +57,28 @@ pub struct ProxyCapabilities {
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)]
#[serde(rename_all = "camelCase")]
pub struct ProxyGroupView {
@@ -76,6 +98,9 @@ pub struct ProxyGroupView {
pub test_url: Option<String>,
#[specta(type = Vec<crate::ipc::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)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
@@ -91,6 +116,9 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType,
#[specta(type = Vec<crate::ipc::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")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -256,7 +284,7 @@ impl ProxyViewBuilder {
} else {
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 (providers, provider_candidates) = build_provider_records(providers, &mut records);
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 nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
if proxy.all.is_some() {
groups.insert(name, proxy);
} else {
} else if !providers_available || proxy.provider_name.is_empty() {
nodes.insert(name, proxy);
}
}
@@ -403,6 +431,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
now,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -435,6 +464,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon,
test_url,
history,
extra,
capabilities: ProxyCapabilities {
udp,
xudp,
@@ -453,6 +483,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
icon,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -467,6 +498,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name,
proxy_type,
history,
extra,
id,
hidden,
icon,
@@ -520,3 +552,62 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
);
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",
profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies",
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
+357 -136
View File
@@ -1,5 +1,6 @@
use crate::config::{IProfilePreview, IVerge};
use crate::core::lightweight;
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler;
use crate::singleton;
@@ -14,7 +15,6 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
use super::handle;
@@ -28,6 +28,7 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
mod latency;
mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
@@ -55,8 +56,25 @@ enum IconKind {
Tun,
}
struct LatencyMenuItem {
item: MenuItem<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray {
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")]
speed_controller: speed_task::TraySpeedController,
}
@@ -130,6 +148,9 @@ impl Default for Tray {
fn default() -> Self {
Self {
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")]
speed_controller: speed_task::TraySpeedController::new(),
}
@@ -195,6 +216,7 @@ impl Tray {
}
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 {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(());
@@ -219,29 +241,133 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
logging_error!(
Type::Tray,
tray.set_menu(Some(
create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?,
))
);
let (menu, latency_items) = create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?;
tray.set_menu(Some(menu))?;
let groups: Vec<_> = latency_items.keys().cloned().collect();
*self.latency_items.lock() = latency_items;
for group in groups {
self.refresh_latency_item(&group);
}
logging!(debug, Type::Tray, "托盘菜单更新成功");
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<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -457,96 +583,175 @@ fn create_profile_menu_item(
.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(
app_handle: &AppHandle,
proxy_mode: &str,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
view: Option<ProxyViewV1>,
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
) -> Vec<Submenu<Wry>> {
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
if let Some(proxy_nodes_data) = proxy_nodes_data {
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
let should_show = match proxy_mode {
"global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL",
} && !group_data.hidden.unwrap_or_default();
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 Some(view) = view else { return Vec::new() };
view.groups
.iter()
.chain(view.global.iter())
.filter_map(|group| {
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
return None;
}
}
if let Some(order_map) = proxy_group_order_map.as_ref() {
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
match (order_map.get(name_a), order_map.get(name_b)) {
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => original_index_a.cmp(original_index_b),
}
});
}
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
};
proxy_submenus
let url = latency::test_url(group, verge, profiles);
let proxies: Vec<ProxyLatencyItem> = group
.members
.iter()
.filter_map(|member| {
let name = member_name(member);
let text = proxy_item_text(&view, member, &url, verge);
let checked = group.now.as_deref() == Some(name);
CheckMenuItem::with_id(
app_handle,
format!("proxy_{}_{}", group.name, name),
&text,
true,
checked,
None::<&str>,
)
.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<Wry>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
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(
@@ -593,54 +798,38 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> {
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime = Config::runtime().await.latest_arc();
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let proxy_view = if fetch_proxy_groups {
tokio::time::timeout(
Duration::from_millis(1000),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
.ok()
.and_then(Result::ok)
} else {
(None, None)
None
};
if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc();
}
let profiles_config = Config::profiles().await.latest_arc();
let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
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 version = env!("CARGO_PKG_VERSION");
@@ -720,9 +909,17 @@ async fn create_tray_menu(
&profile_menu_items_refs,
)?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
let proxy_sub_menus = create_subcreate_proxy_menu_item(
app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -866,10 +1063,25 @@ async fn create_tray_menu(
]);
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) {
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!(
tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -991,6 +1203,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
};
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_") => {
let rest = match id.strip_prefix("proxy_") {
Some(r) => r,
+3 -1
View File
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}");
}
pub async fn quit() {
pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting();
@@ -165,11 +165,13 @@ pub async fn quit() {
NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return clash_verge_signal::ShutdownOutcome::Canceled;
}
utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
clash_verge_signal::ShutdownOutcome::Committed
}
pub async fn clean_async() -> CleanupResult {
-1
View File
@@ -230,7 +230,6 @@ pub fn run() -> std::process::ExitCode {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async();
resolve::resolve_setup_sync();
#[cfg(target_os = "linux")]
resolve::init_signal();
})) {
log_setup_panic("window-core", panic);
+1 -1
View File
@@ -16,7 +16,7 @@ fn main() -> ExitCode {
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode {
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;
#[allow(clippy::unwrap_used)]
+1 -2
View File
@@ -20,6 +20,7 @@ use crate::{
utils::{init, server, window_manager::WindowManager},
};
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")]
pub mod dns;
@@ -188,8 +189,6 @@ async fn init_silent_updater() {
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() {
logging!(debug, Type::Setup, "Initializing signal handlers...");
clash_verge_signal::register(feat::quit);
+52 -1
View File
@@ -39,6 +39,8 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
const PAC_INITIAL_AVAILABLE: bool = false;
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
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)]
pub enum SingletonDisposition {
@@ -162,12 +164,55 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
.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) {
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
#[cfg(feature = "verge-dev")]
let auth = instance_auth(token);
let auth = instance_auth(token.clone());
#[cfg(not(feature = "verge-dev"))]
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);
#[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 {
warp::serve(commands)
.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 NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
/// (button_height, mono_advance, attributes).
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
thread_local! {
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 {
@@ -78,26 +74,8 @@ fn create_attributed_string(
}
}
/// Attributes and per-glyph advance, rebuilt only when the button height changes.
fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) {
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 status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 {
(attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH)
}
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.
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 {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
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 {
return;
};
let ns_string = NSString::from_str(text);
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 attr_str = create_attributed_string(&ns_string, Some(attrs));
let width = arithmetic_width(text, advance);
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
let attr_str = create_attributed_string(text, Some(attrs));
let length = status_item_length_for_speed(&attr_str);
if status_item.length() != length {
status_item.setLength(length);
}
attr_str
} else {
create_attributed_string(&ns_string, None)
create_attributed_string(text, None)
};
button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button);
@@ -145,7 +121,8 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return;
}
*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.borrow_mut().clear();
});
let empty = NSString::from_str("");
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);
}
@@ -47,7 +47,10 @@ import {
useCoreDataStatus,
useProxiesData,
} from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { profileTestUrls } from '@/services/proxy-test-url'
import {
findCurrentGroupMember,
getRecord,
@@ -675,6 +678,20 @@ export const CurrentProxyCard = () => {
[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(
() => optionsForGroup(selectedGroup),
[optionsForGroup, selectedGroup],
@@ -907,6 +924,12 @@ export const CurrentProxyCard = () => {
}
refreshProxy()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
})
})
const proxyOptions = useMemo(
-7
View File
@@ -75,7 +75,6 @@ interface ProxyChainProps {
onMarkUnsavedChanges?: () => void
mode?: string
selectedGroup?: string | null
onExitChainMode: () => void
}
interface ProxyChainItemProps {
@@ -332,7 +331,6 @@ export const ProxyChain = ({
onMarkUnsavedChanges,
mode,
selectedGroup,
onExitChainMode,
}: ProxyChainProps) => {
const theme = useTheme()
const { t } = useTranslation()
@@ -698,18 +696,13 @@ export const ProxyChain = ({
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
height: '100%',
color: theme.palette.text.secondary,
}}
>
<Typography>{t('proxies.page.chain.empty')}</Typography>
<Button size="small" variant="outlined" onClick={onExitChainMode}>
{t('shared.actions.close')}
</Button>
</Box>
) : (
<DragDropProvider
+11 -6
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
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 { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { isValidLatencyTestUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)}
{textState === 'url' && (
<TextField
<ProxyTestUrlInput
name={groupName}
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault()
e.stopPropagation()
}}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
onCommit={(testUrl) => onHeadState({ testUrl })}
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') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -77,7 +77,6 @@ interface GroupSelectMenuProps {
interface ProxyGroupsChainProps {
mode: string
chainConfigData?: string | null
onExitChainMode: () => void
availableGroups: any[]
activeSelectedGroup: string | null
showScrollTop: boolean
@@ -306,7 +305,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
const {
mode,
chainConfigData,
onExitChainMode,
availableGroups,
activeSelectedGroup,
showScrollTop,
@@ -523,7 +521,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
chainConfigData={chainConfigData}
mode={mode}
selectedGroup={activeSelectedGroup}
onExitChainMode={onExitChainMode}
/>
</Box>
</Box>
+16 -24
View File
@@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import {
isInteractableMember,
resolveMember,
@@ -42,7 +44,6 @@ import {
ProxyGroupNavigator,
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
@@ -69,7 +70,6 @@ interface Props {
mode: string
isChainMode?: boolean
chainConfigData?: string | null
onExitChainMode: () => void
}
function useEmptyRenderList() {
@@ -90,11 +90,11 @@ function useProxyRenderState(
) {
const { verge } = useVerge()
const { proxyView } = useProxiesData()
const {
renderList,
onProxies,
onHeadState: setListHeadState,
} = useRenderList(mode, isChainMode, activeSelectedGroup)
const { renderList, onProxies, onHeadState } = useRenderList(
mode,
isChainMode,
activeSelectedGroup,
)
const scrollPositionKey = useMemo(
() =>
isChainMode
@@ -103,14 +103,6 @@ function useProxyRenderState(
[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 handleCheckAll = useStableCallback(
@@ -143,6 +135,12 @@ function useProxyRenderState(
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
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: {
mode: string
chainConfigData?: string | null
onExitChainMode: () => void
}) {
const { mode, chainConfigData, onExitChainMode } = props
const { mode, chainConfigData } = props
const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@@ -349,7 +346,6 @@ function ChainProxyGroups(props: {
<ProxyGroupsChain
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop}
@@ -621,7 +617,7 @@ function NormalProxyGroups(props: { mode: string }) {
}
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props
const { mode, isChainMode = false, chainConfigData } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData()
@@ -643,11 +639,7 @@ export const ProxyGroups = (props: Props) => {
return <ProxyEmptyState reason={listState.reason} />
case 'render':
return isChainMode ? (
<ChainProxyGroups
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
) : (
<NormalProxyGroups mode={mode} />
)
+11 -6
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded,
SortRounded,
} 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 { useTranslation } from 'react-i18next'
@@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
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 { HeadState } from './use-head-state'
@@ -72,7 +73,10 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204'
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url])
return (
@@ -97,7 +101,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -188,7 +192,8 @@ export const ProxyHead = ({
)}
{textState === 'url' && (
<TextField
<ProxyTestUrlInput
name={groupName}
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
@@ -197,7 +202,7 @@ export const ProxyHead = ({
size="small"
variant="outlined"
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 } }}
/>
)}
@@ -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',
ref: { kind: 'node', name, recordId: `${memberIndex}` },
node: {
recordId: `${memberIndex}`,
history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
history: [{ time: new Date(0).toISOString(), delay }],
},
},
source: { kind: 'provider', providerName: `${memberIndex}` },
},
},
}) as ResolvedMemberOccurrence
}) as unknown as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks())
@@ -36,7 +42,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
for (const cached of [false, true]) {
const group = `sort-${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]) {
const expected = list
@@ -66,7 +74,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
node(3, 25),
]
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')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
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[0],
])
delayManager.setDelay('0', 'expiry', 2)
delayManager.setDelay(list[0].member, 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear()
filterSort([], 'expiry', '', 1)
+60 -18
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useReducer } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
import {
profileTestUrls,
commitProfileTestUrl,
} from '@/services/proxy-test-url'
import { ProxySortType } from './use-filter-sort'
@@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = {
}
type HeadStateAction =
| { type: 'reset' }
| { type: 'replace'; payload: Record<string, HeadState> }
| { type: 'reset'; profile: string }
| { type: 'replace'; profile: string; payload: Record<string, 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(
state: Record<string, HeadState>,
state: LoadedHeadState,
action: HeadStateAction,
): Record<string, HeadState> {
): LoadedHeadState {
switch (action.type) {
case 'reset':
return {}
return { profile: action.profile, heads: {} }
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': {
const prev = state[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
const prev = state.heads[action.groupName] || DEFAULT_STATE
return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
}
default:
return state
@@ -55,30 +82,42 @@ function headStateReducer(
}
export function useHeadStateNew() {
const { profiles } = useProfiles()
const { profiles, current: profile } = useProfiles()
const current = profiles?.current || ''
const [state, dispatch] = useReducer(headStateReducer, {})
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
useEffect(() => {
const urls = profile ? profileTestUrls(profile) : {}
if (loadedProfile === current) {
dispatch({ type: 'urls', urls })
return
}
try {
const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!,
localStorage.getItem(HEAD_STATE_KEY) || '{}',
) 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') {
dispatch({ type: 'replace', payload: value })
dispatch({ type: 'replace', profile: current, payload: value })
} else {
dispatch({ type: 'reset' })
dispatch({ type: 'reset', profile: current })
}
} catch {
dispatch({ type: 'reset' })
dispatch({ type: 'reset', profile: current })
}
}, [current])
}, [current, loadedProfile, profile])
useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -94,13 +133,16 @@ export function useHeadStateNew() {
})
return () => clearTimeout(timer)
}, [state, current])
}, [state, current, loadedProfile])
const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
},
[],
[current],
)
return [state, setHeadState] as const
+1
View File
@@ -116,6 +116,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false,
smux: false,
history: [],
extra: {},
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 { memo, useEffect, useRef } from 'react'
import { useState, useRef, memo, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
@@ -34,6 +43,7 @@ export const BackupConfigViewer = memo(
const { t } = useTranslation()
const { verge, mutateVerge } = useVerge()
const { webdav_url, webdav_username, webdav_password } = verge || {}
const [showPassword, setShowPassword] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const urlRef = useRef<HTMLInputElement>(null)
@@ -58,6 +68,10 @@ export const BackupConfigViewer = memo(
const webdavStatus = getWebdavStatus(webdavSignature)
const shouldAutoInit = webdavStatus !== 'failed'
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev)
}
useEffect(() => {
if (!webdav_url || !webdav_username || !webdav_password) {
return
@@ -201,7 +215,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}>
<TextField
label={t('shared.labels.password')}
type="password"
type={showPassword ? 'text' : 'password'}
variant="outlined"
size="small"
autoCorrect="off"
@@ -209,6 +223,20 @@ export const BackupConfigViewer = memo(
spellCheck="false"
inputRef={passwordRef}
{...register('password')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
</Grid>
+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 { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { migrateProfileTestUrls } from '@/services/proxy-test-url'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
@@ -35,6 +36,10 @@ export const useProfiles = () => {
refetchInterval: false,
})
useEffect(() => {
if (profiles) migrateProfileTestUrls(profiles)
}, [profiles])
const refetchRef = useRef(refetch)
refetchRef.current = refetch
const mutateProfiles = useCallback(async () => {
@@ -46,7 +51,6 @@ export const useProfiles = () => {
try {
const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config',
errorNotice: false,
onFulfilled: (outcome) => {
if (outcome.status === 'valid') {
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
if (outcome.status !== 'valid') {
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles()
}
return outcome
+12 -9
View File
@@ -34,7 +34,6 @@ export function useProxyDelayState(
groupName: string,
): UseProxyDelayState {
const name = member.ref.name
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
@@ -43,18 +42,18 @@ export function useProxyDelayState(
useEffect(() => {
if (isPreset) return
delayManager.setListener(name, groupName, setDelayState)
delayManager.setListener(member, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
delayManager.removeListener(member, groupName)
}
}, [name, groupName, isPreset])
}, [member, groupName, isPreset])
const updateDelay = useCallback(() => {
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
const cachedUpdate = delayManager.getDelayUpdate(member, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
@@ -67,7 +66,9 @@ export function useProxyDelayState(
}
let updatedAt = 0
const history = details?.history
const history =
delayManager.getHistory(member, groupName) ??
memberDetails(member)?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
@@ -77,16 +78,18 @@ export function useProxyDelayState(
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [details?.history, groupName, member, name, unresolved])
}, [groupName, member, unresolved])
useEffect(() => {
updateDelay()
}, [updateDelay])
return delayManager.addGroupListener(groupName, updateDelay)
}, [groupName, updateDelay])
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
await delayManager.checkDelay(member, groupName, timeout)
updateDelay()
})
return {
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Klicken Sie die Knoten nacheinander an, um sie zur Proxy-Kette hinzuzufügen",
"minimumNodes": "Eine Proxy-Kette braucht mindestens 2 Knoten",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"header": "체인 프록시 구성",
"warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"confirmEnable": "이해했습니다, 활성화",
"keepDisabled": "비활성화 유지",
"empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요",
"minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy",
"minimumNodes": "O proxy em cadeia requer pelo menos 2 nós",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"header": "Цепочка прокси",
"warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"confirmEnable": "Понятно, включить",
"keepDisabled": "Оставить выключенным",
"empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку",
"minimumNodes": "Для цепочки требуется минимум 2 узла",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın",
"minimumNodes": "Zincir vekil en az 2 düğüm gerektirir",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"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.",
"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.",
"instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"header": "代理链配置",
"warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"confirmEnable": "我明白,开启",
"keepDisabled": "保持关闭",
"empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"instruction": "顺序点击节点添加到代理链中",
"minimumNodes": "链式代理至少需要 2 个节点",
-2
View File
@@ -70,8 +70,6 @@
"chain": {
"header": "鏈式代理設定",
"warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"confirmEnable": "我明白,開啟",
"keepDisabled": "保持關閉",
"empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"instruction": "依序點擊節點新增到鏈式代理中",
"minimumNodes": "鏈式代理至少需要 2 個節點",
+24 -43
View File
@@ -1,10 +1,10 @@
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 { useCallback, useEffect, useReducer, useState } from 'react'
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 { ProxyGroups } from '@/components/proxy/proxy-groups'
import {
@@ -38,7 +38,6 @@ const ProxyPage = () => {
return false
}
})
const [chainConfirmOpen, setChainConfirmOpen] = useState(false)
const [chainConfigData, dispatchChainConfigData] = useReducer(
(_: string | null, action: string | null) => action,
@@ -69,34 +68,32 @@ const ProxyPage = () => {
}
})
const applyChainMode = (enabled: boolean) => {
setIsChainMode(enabled)
localStorage.setItem('proxy-chain-mode-enabled', enabled.toString())
}
const onToggleChainMode = useLockFn(async () => {
const newChainMode = !isChainMode
const onExitChainMode = useLockFn(async () => {
// 退出链式代理模式时,清除链式代理配置
try {
debugLog('Exiting chain mode, clearing chain configuration')
const result = await mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
if (!result.ok) return
debugLog('Chain configuration cleared successfully')
} catch (error) {
console.error('Failed to clear chain configuration:', error)
showNotice.error(error)
return
if (!newChainMode) {
// 退出链式代理模式时,清除链式代理配置
try {
debugLog('Exiting chain mode, clearing chain configuration')
const result = await mutate(
() => updateProxyChainConfigInRuntime(null),
{
id: 'update-proxy-chain-runtime',
errorNotice: false,
},
)
if (!result.ok) return
debugLog('Chain configuration cleared successfully')
} 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(() => {
if (!isChainMode) {
@@ -205,23 +202,7 @@ const ProxyPage = () => {
mode={curMode ?? 'rule'}
isChainMode={isChainMode}
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>
)
}
+12
View File
@@ -659,6 +659,7 @@ export type PrfItem_Deserialize = {
desc?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
option?: PrfOption_Deserialize | null
@@ -674,6 +675,7 @@ export type PrfItem_Serialize = {
desc?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated: number | null
option?: PrfOption_Serialize | null
@@ -768,6 +770,7 @@ export type ProfilePatch_Deserialize = {
file?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
option?: PrfOption_Deserialize | null
@@ -780,6 +783,7 @@ export type ProfilePatch_Serialize = {
file: string | null
url: string | null
selected: PrfSelected[] | null
latency_test_urls: { [key in string]: string } | null
extra: PrfExtra | null
updated: number | null
option: PrfOption_Serialize | null
@@ -814,6 +818,7 @@ export type ProxyGroupView_Deserialize = {
icon: string | null
testUrl: string | null
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[]
} & ProxyCapabilities
@@ -826,9 +831,14 @@ export type ProxyGroupView_Serialize = {
icon?: string | null
testUrl?: string | null
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[]
} & ProxyCapabilities
export type ProxyLatencyHistory = {
history: DelayHistory[]
}
export type ProxyMemberRef =
| { kind: 'group'; name: string }
| { kind: 'node'; name: string; recordId: string }
@@ -850,6 +860,7 @@ export type ProxyNodeView_Deserialize = {
name: string
type: string
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id: string | null
hidden: boolean | null
icon: string | null
@@ -862,6 +873,7 @@ export type ProxyNodeView_Serialize = {
name: string
type: string
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id?: string | null
hidden?: boolean | null
icon?: string | null
+13 -2
View File
@@ -91,8 +91,18 @@ export async function deleteProfile(index: string) {
}
export async function patchProfile(index: string, profile: ProfilePatch) {
const { type, name, desc, file, url, selected, extra, updated, option } =
profile
const {
type,
name,
desc,
file,
url,
selected,
latency_test_urls,
extra,
updated,
option,
} = profile
return commands.patchProfile(index, {
type,
name,
@@ -100,6 +110,7 @@ export async function patchProfile(index: string, profile: ProfilePatch) {
file,
url,
selected,
latency_test_urls,
extra,
updated,
option,
+133
View File
@@ -59,3 +59,136 @@ describe('group delay completion', () => {
stop()
})
})
test('uses newer core history after a tray test without hiding an active page test', () => {
const member = node('tray-refresh')
if (member.kind !== 'node') throw new Error('Expected node')
const cached = delayManager.setDelay(member, 'tray-group', 50)
member.node.extra = {
[delayManager.getUrl('tray-group')]: {
history: [
{ time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 },
],
},
}
expect(delayManager.getDelayFix(member, 'tray-group')).toBe(300)
expect(delayManager.getDelayUpdate(member, 'tray-group')?.delay).toBe(300)
delayManager.setDelay(member, 'tray-group', -2)
expect(delayManager.getDelayFix(member, 'tray-group')).toBe(-2)
})
test('latency URL validation rejects malformed hosts and ports and trims valid URLs', () => {
for (const url of [
'http://?',
'http://host:invalid',
'http://bad host/',
'ftp://localhost',
'http:localhost',
]) {
delayManager.setUrl('url-validation', url)
expect(delayManager.getUrl('url-validation')).toBe(
'http://cp.cloudflare.com/generate_204',
)
}
delayManager.setUrl('url-validation', ' HTTP://localhost:8080/ ')
expect(delayManager.getUrl('url-validation')).toBe('HTTP://localhost:8080/')
})
test('another URL cannot replace a group measurement, but a newer matching URL can', () => {
const member = node('url-isolation')
if (member.kind !== 'node') throw new Error('Expected node')
delayManager.setUrl('url-a', 'http://localhost/a')
const cached = delayManager.setDelay(member, 'url-a', 50)
const newer = new Date(cached.updatedAt + 1000).toISOString()
Object.assign(member.node, {
history: [{ time: newer, delay: 126 }],
extra: {
'http://localhost/a': {
history: [
{ time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 },
],
},
'http://localhost/b': {
history: [{ time: newer, delay: 126 }],
},
},
})
expect(delayManager.getDelayFix(member, 'url-a')).toBe(50)
Object.assign(member.node, {
extra: {
'http://localhost/a': {
history: [{ time: newer, delay: 75 }],
},
},
})
expect(delayManager.getDelayFix(member, 'url-a')).toBe(75)
})
test('same-named provider members keep separate measurements and listeners', async () => {
const first = node('same-provider-name')
const second = node('same-provider-name')
if (first.kind !== 'node' || second.kind !== 'node')
throw new Error('Expected nodes')
first.node.recordId = 'provider-a:N'
first.node.source = { kind: 'provider', providerName: 'a', proxyName: 'N' }
second.node.recordId = 'provider-b:N'
second.node.source = { kind: 'provider', providerName: 'b', proxyName: 'N' }
const { healthcheckNodeInProvider } = await import('tauri-plugin-mihomo-api')
vi.mocked(healthcheckNodeInProvider)
.mockResolvedValueOnce({ delay: 40 })
.mockResolvedValueOnce({ delay: 125 })
const firstListener = vi.fn()
const secondListener = vi.fn()
delayManager.setListener(first, 'provider-collision', firstListener)
delayManager.setListener(second, 'provider-collision', secondListener)
await delayManager.checkListDelay([first, second], 'provider-collision', 5000)
await flush()
expect(firstListener).toHaveBeenLastCalledWith(
expect.objectContaining({ delay: 40 }),
)
expect(secondListener).toHaveBeenLastCalledWith(
expect.objectContaining({ delay: 125 }),
)
delayManager.removeListener(first, 'provider-collision')
delayManager.removeListener(second, 'provider-collision')
expect(delayManager.getDelayFix(first, 'provider-collision')).toBe(40)
expect(delayManager.getDelayFix(second, 'provider-collision')).toBe(125)
})
test.each(['core', 'provider'] as const)(
'cached %s delays follow source identity after record IDs move',
(kind) => {
const original = node(`stable-${kind}`)
const replacement = node(`replacement-${kind}`)
if (original.kind !== 'node' || replacement.kind !== 'node')
throw new Error('Expected nodes')
const source = (proxyName: string) =>
kind === 'core'
? { kind, proxyName }
: { kind, providerName: 'subscription', proxyName }
original.node.source = source(original.ref.name)
replacement.node.source = source(replacement.ref.name)
original.node.recordId = 'position:0'
replacement.node.recordId = 'position:0'
delayManager.setDelay(original, `reorder-${kind}`, 40)
expect(delayManager.getDelayFix(replacement, `reorder-${kind}`)).toBe(-1)
original.node.recordId = 'position:1'
expect(delayManager.getDelayFix(original, `reorder-${kind}`)).toBe(40)
},
)
test('unmeasured selectors show automatic history without replacing a manual URL result', () => {
const member = node('selector-member')
if (member.kind !== 'node') throw new Error('Expected node')
member.node.history = [
{ time: new Date(Date.now() + 1000).toISOString(), delay: 126 },
]
member.node.extra = {
'http://localhost/automatic': { history: member.node.history },
}
expect(delayManager.getDelayFix(member, 'selector')).toBe(126)
delayManager.setDelay(member, 'selector', 50)
expect(delayManager.getDelayFix(member, 'selector')).toBe(50)
})
+58 -38
View File
@@ -12,13 +12,23 @@ import {
} from '@/types/proxy-view'
import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidUrl } from '@/utils/network'
import { isValidLatencyTestUrl } from '@/utils/network'
export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number
}
const hashKey = (name: string, group: string) => `${group ?? ''}::${name}`
const hashKey = (member: ResolvedProxyMember, group: string) => {
if (member.kind !== 'node')
return JSON.stringify([group, member.kind, member.ref.name])
const source = member.node.source
return JSON.stringify([
group,
source.kind,
source.kind === 'provider' ? source.providerName : null,
source.proxyName,
])
}
export interface DelayUpdate {
delay: number
@@ -29,7 +39,7 @@ export interface DelayUpdate {
const CACHE_TTL = 30 * 60 * 1000
class DelayManager {
private cache = new Map<string, DelayUpdate>()
private cache = new Map<string, DelayUpdate & { url: string }>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
@@ -153,13 +163,13 @@ class DelayManager {
}
setUrl(group: string, url: string) {
if (!isValidUrl(url)) {
debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`)
const previous = this.getUrl(group)
if (isValidLatencyTestUrl(url)) {
this.urlMap.set(group, url.trim())
} else {
this.urlMap.delete(group)
return
}
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
this.urlMap.set(group, url)
if (previous !== this.getUrl(group)) this.queueGroupNotification(group)
}
getUrl(group: string) {
@@ -171,16 +181,16 @@ class DelayManager {
}
setListener(
name: string,
member: ResolvedProxyMember,
group: string,
listener: (update: DelayUpdate) => void,
) {
const key = hashKey(name, group)
const key = hashKey(member, group)
this.listenerMap.set(key, listener)
}
removeListener(name: string, group: string) {
const key = hashKey(name, group)
removeListener(member: ResolvedProxyMember, group: string) {
const key = hashKey(member, group)
this.listenerMap.delete(key)
}
@@ -199,14 +209,14 @@ class DelayManager {
}
setDelay(
name: string,
member: ResolvedProxyMember,
group: string,
delay: number,
meta?: { elapsed?: number },
meta?: { elapsed?: number; url?: string },
): DelayUpdate {
const key = hashKey(name, group)
const key = hashKey(member, group)
debugLog(
`[DelayManager] 设置延迟,代理: ${name}, 组: ${group}, 延迟: ${delay}`,
`[DelayManager] 设置延迟,代理: ${member.ref.name}, 组: ${group}, 延迟: ${delay}`,
)
const update: DelayUpdate = {
delay,
@@ -214,7 +224,9 @@ class DelayManager {
updatedAt: Date.now(),
}
this.cache.set(key, update)
const url = meta?.url ?? this.getUrl(group)
if (url !== this.getUrl(group)) return update
this.cache.set(key, { ...update, url })
const queue = this.pendingItemUpdates.get(key)
if (queue) {
@@ -227,9 +239,16 @@ class DelayManager {
return update
}
getDelayUpdate(name: string, group: string) {
const key = hashKey(name, group)
const entry = this.cache.get(key)
getDelayUpdate(member: ResolvedProxyMember, group: string) {
const history = this.getHistory(member, group)
const key = hashKey(member, group)
const cached = this.cache.get(key)
const entry = cached?.url === this.getUrl(group) ? cached : undefined
const latest = history?.at(-1)
const updatedAt = latest ? Date.parse(latest.time) : 0
if (latest && entry?.delay !== -2 && updatedAt > (entry?.updatedAt ?? 0)) {
return { delay: latest.delay || 1e6, updatedAt }
}
if (!entry) return undefined
if (Date.now() - entry.updatedAt > CACHE_TTL) {
@@ -240,23 +259,25 @@ class DelayManager {
return { ...entry }
}
getDelay(name: string, group: string) {
const update = this.getDelayUpdate(name, group)
getDelay(member: ResolvedProxyMember, group: string) {
const update = this.getDelayUpdate(member, group)
return update ? update.delay : -1
}
getHistory(member: ResolvedProxyMember, group: string) {
return memberDetails(member)?.extra?.[this.getUrl(group)]?.history
}
getDelayFix(member: ResolvedProxyMember, group: string) {
if (member.kind === 'unresolved') return -1
const details = memberDetails(member)
const name = member.ref.name
const update = this.getDelayUpdate(name, group)
const history =
this.getHistory(member, group) ?? memberDetails(member)?.history
const update = this.getDelayUpdate(member, group)
if (update && (update.delay >= 0 || update.delay === -2)) {
return update.delay
}
if (details?.history && details.history.length > 0) {
return details.history[details.history.length - 1].delay || 1e6
}
if (history?.length) return history[history.length - 1].delay || 1e6
return -1
}
@@ -298,12 +319,12 @@ class DelayManager {
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
)
this.setDelay(name, group, -2)
const url = this.getUrl(group)
this.setDelay(member, group, -2, { url })
const startTime = Date.now()
try {
const url = this.getUrl(group)
debugLog(`[DelayManager] 调用API测试延迟,代理: ${name}, URL: ${url}`)
const timeoutPromise = new Promise<ProxyDelay>((resolve) => {
@@ -324,14 +345,14 @@ class DelayManager {
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
return this.setDelay(name, group, delay, { elapsed })
return this.setDelay(member, group, delay, { elapsed, url })
} catch (error) {
await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error
const elapsed = Date.now() - startTime
return this.setDelay(name, group, delay, { elapsed })
return this.setDelay(member, group, delay, { elapsed, url })
}
}
@@ -344,10 +365,9 @@ class DelayManager {
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
const names = proxies.map((member) => member.ref.name)
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
names.forEach((name) => {
this.setDelay(name, group, -2)
proxies.forEach((member) => {
this.setDelay(member, group, -2)
})
let index = 0
@@ -359,7 +379,7 @@ class DelayManager {
const currName = currMember.ref.name
try {
this.setDelay(currName, group, -2)
this.setDelay(currMember, group, -2)
// Stagger requests so a batch does not hit the core at once.
if (index > 1) {
@@ -375,13 +395,13 @@ class DelayManager {
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
this.setDelay(currName, group, 1e6)
this.setDelay(currMember, group, 1e6)
}
return help()
}
const actualConcurrency = Math.min(concurrency, names.length, 10)
const actualConcurrency = Math.min(concurrency, proxies.length, 10)
debugLog(`[DelayManager] 实际并发数: ${actualConcurrency}`)
const promiseList: Promise<void>[] = []
+216 -207
View File
@@ -32,221 +32,230 @@ export const forgetShownStartupError = () => {
* type makes a backend status without a frontend entry a compile error (and
* contract regeneration keeps the union in sync).
*/
const handlers: Record<
NoticeStatus,
(msg: string, navigate: NavigateFunction) => void
> = {
info: (msg) => {
if (msg) showNotice.info(msg)
},
'import_sub_url::ok': (_msg, navigate) => {
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
// navigate("/profile", { state: { current: msg } });
navigate('/profile')
showNotice.success(
'shared.feedback.notifications.importSubscriptionSuccess',
)
},
'import_sub_url::error': (msg, navigate) => {
navigate('/profile')
showNotice.error(msg)
},
'set_config::error': (msg) => showNotice.error(msg),
'core_start::error': () => {
const read = ++startupErrorReads
void getCoreStartupError()
.then(async (failure) => {
if (read < settledStartupErrorRead) return
settledStartupErrorRead = read
if (!failure) {
shownStartupError = null
return
}
const window = getCurrentWindow()
const [visible, minimized] = await Promise.all([
window.isVisible(),
window.isMinimized(),
])
const shown = `${failure.kind}:${failure.detail}`
if (
read === settledStartupErrorRead &&
visible &&
!minimized &&
shownStartupError !== shown
) {
shownStartupError = shown
showNotice.error(
failure.kind === 'serviceCoreStopped'
? 'settings.feedback.errors.clash.serviceCoreStopped'
: 'settings.feedback.errors.clash.startFailed',
failure.detail,
)
}
})
.catch((error) => {
console.error('Failed to read the pending core startup error', error)
})
},
'service_core::repair_required': () => {
void takeServiceRepairNotice()
.then((pending) => {
if (pending) {
requestService({ reason: 'serviceLocationRefused' })
}
})
.catch((error) => {
console.error('Failed to read the pending service repair notice', error)
})
},
'service_core::app_data_not_owned': () => {
void takeServiceOwnerNotice()
.then((command) => {
if (command) {
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
shownOwnerNotice = showNotice.warning(
'settings.feedback.notifications.clashService.appDataNotOwned',
{ command },
0,
)
}
})
.catch((error) => {
console.error('Failed to read the pending service owner notice', error)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((notice) => {
if (notice?.kind === 'coreRejected') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: notice.reason },
0,
)
} else if (notice?.kind === 'notAutoStarted') {
requestService({ reason: 'serviceNotAutoStarted' })
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
if (pending) {
showNotice.info('settings.modals.dns.protection.autoDisabled')
}
})
.catch((error) => {
console.error('Failed to read the pending DNS override notice', error)
})
},
'enhance::discarded_keys': () => {
void takeDiscardedKeysNotice()
.then((keys) => {
if (keys) {
showNotice.warning('profiles.page.feedback.notices.discardedKeys', {
keys,
})
}
})
.catch((error) => {
console.error('Failed to read the pending discarded keys notice', error)
})
},
'tun_mode::auto_disabled': () =>
showNotice.info(
'settings.sections.system.notifications.tunMode.autoDisabled',
),
'tun_mode::auto_disable_failed': () =>
showNotice.error(
'settings.sections.system.notifications.tunMode.autoDisableFailed',
),
'app_restart::core_stop_failed': (msg) =>
showNotice.error(
'layout.feedback.errors.restartCoreStopFailed',
msg || undefined,
),
'app_quit::core_stop_failed': (msg) =>
showNotice.error(
'layout.feedback.errors.quitCoreStopFailed',
msg || undefined,
),
update_with_clash_proxy: (msg) =>
showNotice.success(
'settings.feedback.notifications.updater.withClashProxySuccess',
msg,
),
update_failed_even_with_clash: (msg) =>
showNotice.error(
'settings.feedback.notifications.updater.withClashProxyFailed',
msg,
),
'reactivate_profiles::error': (msg) => showNotice.error(msg),
update_failed: (msg) => showNotice.error(msg),
'config_validate::boot_error': (msg) =>
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
'config_validate::error': (msg) =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::process_terminated': () =>
showNotice.error('shared.feedback.validation.config.processTerminated'),
'config_validate::script_error': (msg) =>
showNotice.error('shared.feedback.validation.script.fileError', msg),
'config_validate::script_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.script.syntaxError', msg),
'config_validate::script_missing_main': (msg) =>
showNotice.error('shared.feedback.validation.script.missingMain', msg),
'config_validate::file_not_found': (msg) =>
showNotice.error('shared.feedback.validation.script.fileNotFound', msg),
'config_validate::yaml_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.syntaxError', msg),
'config_validate::yaml_read_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.readError', msg),
'config_validate::yaml_mapping_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
'config_validate::merge_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
'config_validate::merge_mapping_error': (msg) =>
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
'config_core::change_success': (msg) =>
showNotice.success(
'settings.feedback.notifications.clash.changeSuccess',
msg,
),
'config_core::change_error': (msg) =>
showNotice.error('settings.feedback.notifications.clash.changeFailed', msg),
'mixed_port::fallback': (msg) => {
const [oldPort, newPort] = msg.split(',')
showNotice.info('settings.modals.clashPort.messages.automaticFallback', {
oldPort,
newPort,
})
},
'mixed_port::fallback_error': (msg) =>
showNotice.error(
'settings.modals.clashPort.messages.automaticFallbackFailed',
{ error: msg },
),
}
export const handleNoticeMessage = (
status: string,
msg: string,
t: TranslateFunction,
navigate: NavigateFunction,
) => {
const handlers: Record<NoticeStatus, () => void> = {
info: () => {
if (msg) showNotice.info(msg)
},
'import_sub_url::ok': () => {
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
// navigate("/profile", { state: { current: msg } });
navigate('/profile')
showNotice.success(
'shared.feedback.notifications.importSubscriptionSuccess',
)
},
'import_sub_url::error': () => {
navigate('/profile')
showNotice.error(msg)
},
'set_config::error': () => showNotice.error(msg),
'core_start::error': () => {
const read = ++startupErrorReads
void getCoreStartupError()
.then(async (failure) => {
if (read < settledStartupErrorRead) return
settledStartupErrorRead = read
if (!failure) {
shownStartupError = null
return
}
const window = getCurrentWindow()
const [visible, minimized] = await Promise.all([
window.isVisible(),
window.isMinimized(),
])
const shown = `${failure.kind}:${failure.detail}`
if (
read === settledStartupErrorRead &&
visible &&
!minimized &&
shownStartupError !== shown
) {
shownStartupError = shown
showNotice.error(
failure.kind === 'serviceCoreStopped'
? 'settings.feedback.errors.clash.serviceCoreStopped'
: 'settings.feedback.errors.clash.startFailed',
failure.detail,
)
}
})
.catch((error) => {
console.error('Failed to read the pending core startup error', error)
})
},
'service_core::repair_required': () => {
void takeServiceRepairNotice()
.then((pending) => {
if (pending) {
requestService({ reason: 'serviceLocationRefused' })
}
})
.catch((error) => {
console.error(
'Failed to read the pending service repair notice',
error,
)
})
},
'service_core::app_data_not_owned': () => {
void takeServiceOwnerNotice()
.then((command) => {
if (command) {
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
shownOwnerNotice = showNotice.warning(
'settings.feedback.notifications.clashService.appDataNotOwned',
{ command },
0,
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service owner notice',
error,
)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((notice) => {
if (notice?.kind === 'coreRejected') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: notice.reason },
0,
)
} else if (notice?.kind === 'notAutoStarted') {
requestService({ reason: 'serviceNotAutoStarted' })
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
if (pending) {
showNotice.info('settings.modals.dns.protection.autoDisabled')
}
})
.catch((error) => {
console.error('Failed to read the pending DNS override notice', error)
})
},
'enhance::discarded_keys': () => {
void takeDiscardedKeysNotice()
.then((keys) => {
if (keys) {
showNotice.warning('profiles.page.feedback.notices.discardedKeys', {
keys,
})
}
})
.catch((error) => {
console.error(
'Failed to read the pending discarded keys notice',
error,
)
})
},
'tun_mode::auto_disabled': () =>
showNotice.info(
'settings.sections.system.notifications.tunMode.autoDisabled',
),
'tun_mode::auto_disable_failed': () =>
showNotice.error(
'settings.sections.system.notifications.tunMode.autoDisableFailed',
),
'app_restart::core_stop_failed': () =>
showNotice.error(
'layout.feedback.errors.restartCoreStopFailed',
msg || undefined,
),
'app_quit::core_stop_failed': () =>
showNotice.error(
'layout.feedback.errors.quitCoreStopFailed',
msg || undefined,
),
update_with_clash_proxy: () =>
showNotice.success(
'settings.feedback.notifications.updater.withClashProxySuccess',
msg,
),
update_failed_even_with_clash: () =>
showNotice.error(
'settings.feedback.notifications.updater.withClashProxyFailed',
msg,
),
'reactivate_profiles::error': () => showNotice.error(msg),
update_failed: () => showNotice.error(msg),
'config_validate::boot_error': () =>
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
'config_validate::error': () =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::process_terminated': () =>
showNotice.error('shared.feedback.validation.config.processTerminated'),
'config_validate::script_error': () =>
showNotice.error('shared.feedback.validation.script.fileError', msg),
'config_validate::script_syntax_error': () =>
showNotice.error('shared.feedback.validation.script.syntaxError', msg),
'config_validate::script_missing_main': () =>
showNotice.error('shared.feedback.validation.script.missingMain', msg),
'config_validate::file_not_found': () =>
showNotice.error('shared.feedback.validation.script.fileNotFound', msg),
'config_validate::yaml_syntax_error': () =>
showNotice.error('shared.feedback.validation.yaml.syntaxError', msg),
'config_validate::yaml_read_error': () =>
showNotice.error('shared.feedback.validation.yaml.readError', msg),
'config_validate::yaml_mapping_error': () =>
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
'config_validate::merge_syntax_error': () =>
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
'config_validate::merge_mapping_error': () =>
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
'config_core::change_success': () =>
showNotice.success(
'settings.feedback.notifications.clash.changeSuccess',
msg,
),
'config_core::change_error': () =>
showNotice.error(
'settings.feedback.notifications.clash.changeFailed',
msg,
),
'mixed_port::fallback': () => {
const [oldPort, newPort] = msg.split(',')
showNotice.info('settings.modals.clashPort.messages.automaticFallback', {
oldPort,
newPort,
})
},
'mixed_port::fallback_error': () =>
showNotice.error(
'settings.modals.clashPort.messages.automaticFallbackFailed',
{ error: msg },
),
}
const handler = handlers[status as NoticeStatus]
if (handler) {
handler(msg, navigate)
handler()
} else {
console.warn(`未处理的通知状态: ${status}`)
}
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, expect, test, vi } from 'vitest'
vi.mock('@/services/cmds', () => ({
getProfiles: vi.fn(),
patchProfile: vi.fn().mockResolvedValue(undefined),
syncTrayProxySelection: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } }))
import {
getProfiles,
patchProfile,
syncTrayProxySelection,
} from '@/services/cmds'
import { revalidateQueries } from '@/services/query-client'
import {
profileTestUrls,
saveProfileTestUrls,
commitProfileTestUrl,
} from './proxy-test-url'
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
test('committed edits retain their profile and merge with fresh saved URLs', async () => {
vi.mocked(getProfiles).mockResolvedValue({
current: 'new-profile',
items: [
{
uid: 'old-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { untouched: 'https://example.com/' },
},
],
})
await commitProfileTestUrl('old-profile', 'A', ' http://localhost/new ')
expect(patchProfile).toHaveBeenCalledExactlyOnceWith('old-profile', {
latency_test_urls: {
untouched: 'https://example.com/',
A: 'http://localhost/new',
},
})
expect(revalidateQueries).toHaveBeenCalledWith([['getProfiles']])
expect(syncTrayProxySelection).toHaveBeenCalledOnce()
})
test('migration cannot overwrite a previously saved or cleared override', async () => {
vi.mocked(getProfiles).mockResolvedValue({
items: [
{
uid: 'p',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: '' },
},
],
})
await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true)
expect(patchProfile).not.toHaveBeenCalled()
})
test('pending edits survive a reload and older save acknowledgements', async () => {
vi.useFakeTimers()
const profile = {
uid: 'pending-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: 'http://localhost/old' },
}
vi.mocked(getProfiles).mockResolvedValue({ items: [profile] })
let finish!: () => void
vi.mocked(patchProfile).mockImplementationOnce(
() =>
new Promise<null>((resolve) => {
finish = () => resolve(null)
}),
)
const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
await vi.advanceTimersByTimeAsync(300)
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
const second = commitProfileTestUrl(
profile.uid,
'A',
'http://localhost/newest',
)
finish()
await first
expect(profileTestUrls(profile).A).toBe('http://localhost/newest')
await second
expect(patchProfile).toHaveBeenLastCalledWith(profile.uid, {
latency_test_urls: { A: 'http://localhost/newest' },
})
})
test('a failed save preserves the committed input across profile refreshes', async () => {
const profile = {
uid: 'failed-profile',
type: null,
name: null,
file: null,
updated: null,
latency_test_urls: { A: 'http://localhost/old' },
}
vi.mocked(getProfiles).mockResolvedValue({ items: [profile] })
vi.mocked(patchProfile).mockRejectedValueOnce(new Error('save failed'))
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/new')
await commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new')
expect(profileTestUrls(profile).A).toBe('http://localhost/old')
log.mockRestore()
})
+101
View File
@@ -0,0 +1,101 @@
import {
getProfiles,
patchProfile,
syncTrayProxySelection,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
function readLegacyTestUrls(uid: string): Record<string, string> {
try {
const states = JSON.parse(localStorage.getItem('proxy-head-state') || '{}')[
uid
]
return Object.fromEntries(
Object.entries(states || {}).flatMap(([name, state]) => {
const url = (state as { testUrl?: string }).testUrl
return typeof url === 'string' && url.trim() ? [[name, url.trim()]] : []
}),
)
} catch {
return {}
}
}
export async function saveProfileTestUrls(
uid: string,
urls: Record<string, string>,
migrateOnly = false,
) {
return mutate(
async () => {
const profiles = await getProfiles()
const profile = profiles.items?.find((item) => item.uid === uid)
if (!profile || (migrateOnly && profile.latency_test_urls !== undefined))
return
await patchProfile(uid, {
latency_test_urls: {
...(profile.latency_test_urls ?? readLegacyTestUrls(uid)),
...urls,
},
})
await syncTrayProxySelection().catch((error) => {
console.error(
'Failed to refresh tray after saving latency test URL:',
error,
)
})
},
{ id: `patch-profile:${uid}`, revalidate: [['getProfiles']] },
)
}
const migrating = new Set<string>()
export function migrateProfileTestUrls(profiles: IProfilesConfig) {
for (const profile of profiles.items || []) {
const uid = profile.uid
if (!uid) continue
if (profile.latency_test_urls !== undefined || migrating.has(uid)) continue
const urls = readLegacyTestUrls(uid)
if (!Object.keys(urls).length) continue
migrating.add(uid)
void saveProfileTestUrls(uid, urls, true)
.catch((error) => {
console.error('Failed to migrate latency test URLs:', error)
})
.finally(() => migrating.delete(uid))
}
}
const pending = new Map<string, Record<string, { url: string }>>()
export async function commitProfileTestUrl(
uid: string,
group: string,
url: string,
) {
const edit = { url: url.trim() }
const urls = { ...pending.get(uid), [group]: edit }
pending.set(uid, urls)
try {
await saveProfileTestUrls(uid, { [group]: edit.url })
const current = pending.get(uid)
if (current?.[group] === edit) {
delete current[group]
if (!Object.keys(current).length) pending.delete(uid)
}
} catch (error) {
console.error('Failed to save latency test URL:', error)
}
}
export function profileTestUrls(profile: IProfileItem): Record<string, string> {
return {
...profile.latency_test_urls,
...Object.fromEntries(
Object.entries(
(profile.uid ? pending.get(profile.uid) : undefined) ?? {},
).map(([group, edit]) => [group, edit.url]),
),
}
}
-1
View File
@@ -66,7 +66,6 @@ const withLoading = (
next.delete(uid)
}
}
if (next.size === state.profileUpdates.loading.size) return state
return {
...state,
profileUpdates: { ...state.profileUpdates, loading: next },
-2
View File
@@ -322,8 +322,6 @@ export const translationKeys = [
'proxies.page.empty.actions.openLogs',
'proxies.page.chain.header',
'proxies.page.chain.warning',
'proxies.page.chain.confirmEnable',
'proxies.page.chain.keepDisabled',
'proxies.page.chain.empty',
'proxies.page.chain.instruction',
'proxies.page.chain.minimumNodes',
-2
View File
@@ -525,7 +525,6 @@ export interface TranslationResources {
toggleChain: string
}
chain: {
confirmEnable: string
connectFailed: string
disconnectFailed: string
duplicateNode: string
@@ -534,7 +533,6 @@ export interface TranslationResources {
exitNode: string
header: string
instruction: string
keepDisabled: string
minimumNodes: string
minimumNodesHint: string
warning: string
+13
View File
@@ -20,6 +20,19 @@ export const isValidUrl = (value: string) =>
require_tld: false,
})
export const isValidLatencyTestUrl = (value: string) => {
const raw = value.trim()
if (!/^https?:\/\//i.test(raw)) return false
try {
const url = new URL(raw)
return (
(url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname
)
} catch {
return false
}
}
export const isValidPort = (value: string) => isPort(value.trim())
export const normalizeHost = (value: string): string | null => {