Compare commits

...
Author SHA1 Message Date
renovate[bot] ffdb270380 chore(deps): update dependency node to v26 2026-10-28 02:57:23 +00:00
Shubham Padkonde 3953e125b2 fix(profile): ignore null entries in the rules editor (#8432) 2026-10-27 17:39:05 +00:00
nrenvo cbd5ed73a5 fix(theme): allow injected CSS to style Monaco view containers (#8385)
`@scope`'s `to` bound excludes the boundary elements themselves, so rules
targeting `.view-lines` never matched. Move the bound to its children
with `> *`: the container becomes styleable, its internals stay excluded.
2026-10-27 15:13:58 +00:00
Tunglies 02e5e0e4a6 ci: gate frontend and rust checks by changed paths (#8433)
The paths-filter output in lint-clippy.yml only gated two echo steps;
checkout, toolchain setup and clippy itself ran unconditionally, so
frontend-only PRs still burned a 3-runner matrix on a full clippy build
plus the logging-check tool install.

- lint-clippy/rustfmt/frontend-check: move path filters to workflow
  level so irrelevant PRs spawn no runner at all
- split contract-parity into its own workflow; its trigger set spans
  both sides and workflow paths cannot vary per job
- clippy paths now cover crates/** and workspace Cargo files, which the
  old src-tauri/** filter missed

dev has no required status checks, so path-filtered workflows cannot
get stuck in "Expected".
2026-10-27 16:36:49 +08:00
0498ba3e4f fix(proxies): render configured groups in global mode (#8402)
* fix(proxies): render configured groups in global mode

Global mode previously only rendered the synthetic GLOBAL group and
sliced off its header, which flattened every user-defined group into
a non-expandable proxy row. Configured groups could no longer be
opened or inspected. Render the same group list as rule/script mode
so every group keeps its own header and expand toggle.

Fixes #8376

* style: keep useRule assignment on one line

* fix & changelog

* fix: hover when global proxy

---------

Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-10-27 01:41:19 +00:00
Tunglies f39644ee6b chore(typecheck): replace tsc with tsrs as the typecheck gate (#8428)
tsrs (Rust port of the TypeScript 7 checker) replaces tsc --noEmit in
the typecheck script, web:build, and CI. Measured on this repository:
~0.4s vs ~3.5s wall time and ~280 MB vs ~750 MB peak RSS, with
identical zero-diagnostic results on the current tree. The typescript
package stays installed: typescript-eslint projectService and
scripts/cleanup-unused-i18n.mjs consume its compiler API.
2026-10-27 01:34:29 +00:00
488f022c05 feat(media-unlock): add Muse AI (muse.ai) detection (#8411)
* feat(media-unlock): add Muse AI detection

muse.ai redirects every region to Meta's aymh anti-bot gateway before the app loads, and a plain HTTP client cannot finish the JavaScript challenge. Report the result as a new Risky status (exit reachable, availability unverifiable) together with the exit region instead of a misleading Yes/No.

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

* style(media-unlock): apply rustfmt and biome formatting

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

* docs(changelog): Update changelog

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-26 17:17:09 +00:00
Ekko82 eefd025806 feat(home): add an IP auto-refresh toggle (#8403) 2026-10-26 02:04:55 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 10ca0a5d0c chore(deps): update npm dependencies (#8421)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 16:24:26 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 324c8c9016 chore(deps): update dependency pnpm to v12.15.0 (#8418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 15:35:18 +00:00
oomeow 02520d7392 fix(tray): skip Win-key accelerators on Windows (#8400) 2026-10-25 09:34:09 +00:00
Tunglies 5b8edbaead feat(tun): expose mihomo tun dns-mode in TUN settings
Support the dns-mode field introduced in mihomo v1.19.33. A
button group below the stack selector offers
Auto/Disabled/Native/Hijack; Auto writes an empty string, which
the core treats as the legacy behavior. Adding dns-mode to
GUI_KEYS makes the value GUI-authoritative once saved, matching
the other TUN dialog fields.
2026-10-25 16:04:59 +08:00
Tunglies 1ed8050947 fix(perf): auto-derive WebDriver window rect scale in harness 2026-10-25 13:14:14 +08:00
❤是纱雾酱哟~ 3d9a79a3fb fix(windows): patch executable for common-controls-v6 (#8378)
- Enhance patch logic, also patch for "example" executables

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-22 09:59:10 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8b88ddb7f2 chore(deps): update node.js to v24.22.1 (#8389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-21 17:25:15 +00:00
Tunglies 02bd739ab6 docs: clarify issue and PR intake rules 2026-10-21 19:36:50 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
b6c7af82a7 chore(deps): update tauri (#8346)
* chore(deps): update tauri

* fix(deps): pin rust-ini to 0.21.1 for tauri-plugin-deep-link 2.6.2

tauri-plugin-deep-link 2.6.2 constructs ini::ParseOption with an
exhaustive struct literal, which fails to compile against rust-ini
0.21.2+ (new required fields enabled_indented_mutiline_value and
enabled_preserve_key_leading_whitespace, E0063 on linux). Upstream
fixed it in tauri-apps/plugins-workspace#3711 with ..Default::default()
but no release contains the fix yet, so downgrade rust-ini in Cargo.lock
until deep-link 2.6.3 ships.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-10-21 08:25:55 +00:00
wonfen 5e6a696087 revert(tray): revert per-group latency testing (#8173)
This reverts commit cb751052a4.

Remove the tray latency test and its dashboard URL persistence and cache
changes because the two entry points have diverging behavior.

Preserve the subsequent IPC binding migration and remove generated fields,
input projections, and locale entries that depended on the reverted feature.
2026-10-21 05:37:18 +08:00
❤是纱雾酱哟~ 27d8a71d41 Revert "fix(profiles): refresh remote profile name from server on update (#8360)" (#8366)
This reverts commit 9dd5aa676b.
2026-10-20 15:52:35 +00:00
susu-Sugarand❤是纱雾酱哟~ 9dd5aa676b fix(profiles): refresh remote profile name from server on update (#8360)
* fix(profiles): refresh remote profile name from server on update

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

* chore(changelog): Update changelog for the PR

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-19 17:41:33 +00:00
wonfen 4e5ac8683b feat(proxy): confirm before enabling chain proxy
Show the speed warning with explicit enable/keep-disabled actions, and add a close button to the empty chain panel.
2026-10-19 20:01:43 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
7965711945 chore(deps): update cargo dependencies (#8323)
* chore(deps): update cargo dependencies

* chore(deps): revert dependency rfd to 0.16.0

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-17 16:14:23 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 396d343a3a chore(deps): update npm dependencies (#8335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 13:40:23 +00:00
❤是纱雾酱哟~ b729e59650 Revert "ci(workflows): bump gh-aw from 0.89.21 to 0.92.0 (#8330)" (#8343)
This reverts commit 089635c8bf.
2026-10-17 11:04:13 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a3cc58013b chore(deps): update rust toolchain to v1.99.1 (#8341)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 09:55:28 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> cef66cd745 chore(deps): update dependency pnpm to v12.14.0 (#8340)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-17 09:04:27 +00:00
Tunglies 3b7bfa10fd chore(deps): label the tauri group PR
Tag the coordinated Tauri update PR with a dedicated tauri label so it
stands out from the generic dependency groups.
2026-10-17 13:01:03 +08:00
Tunglies b238175f18 chore(deps): add @wdio/tauri-service to the tauri update group
The WebdriverIO Tauri service pairs with the Tauri stack it drives, so
its updates belong with the coordinated Tauri npm and Cargo upgrades
rather than the generic npm group.
2026-10-17 12:53:14 +08:00
Tunglies 8f00096a9f chore(deps): group Tauri npm and Cargo updates into a single PR
Add a packageRule that pulls @tauri-apps/* packages and tauri/tauri-*
crates out of the generic npm and cargo groups into one shared "tauri"
group, so frontend and backend halves of a Tauri upgrade are validated
together.

Pin tauri-plugin-mihomo by rev instead of branch so Renovate tracks it:
branch-pinned git dependencies are skipped with skipReason
git-dependency and never produce updates. With the rev pin the plugin
joins the tauri group via digest updates. The rev is the commit already
locked in Cargo.lock, so resolution is unchanged.
2026-10-17 12:40:05 +08:00
Tunglies 9f498a85d0 perf(frontend): hoist notice table and skip no-op loading updates
The notice handler table was rebuilt on every notice event; build it
once at module scope. withLoading returned a new state even when the
loading set was unchanged, re-rendering store consumers for nothing.
2026-10-17 10:32:15 +08:00
Tunglies e014b913d2 fix(profile): close switching-flag leak window and drop duplicate busy notice
The switching flag was released by a defer registered inside
perform_config_update, so a panic during the write-lock wait or the
draft edit could leave it stuck set. Register the defer right after the
CAS. Skip the generic validation notice for busy switch outcomes; the
frontend already shows its own busy toast.
2026-10-17 09:14:57 +08:00
Tunglies f38ceae3fb fix(profiles): restore single switch error toast and refetch after busy
patchProfiles entered the mutate funnel without errorNotice:false, so a
failed switch toasted twice. On busy the cached current profile was left
untouched, so a discarded switch draft could poison later clicks on the
target profile. Refetch on every non-valid outcome.
2026-10-17 08:10:16 +08:00
❤是纱雾酱哟~ 089635c8bf ci(workflows): bump gh-aw from 0.89.21 to 0.92.0 (#8330)
- Bump `gh-aw` from 0.89.21 to 0.92.0
- Switch from `pull_request_target` to `pull_request` for CI security
- Set `permissions.copilot-requests` to `none` for suppressing compilation info
2026-10-16 12:11:40 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> dc5a8a5ff4 chore(deps): update node.js to v24.22.0 (#8324)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-16 06:25:50 +00:00
Tunglies 476ddb63c3 fix(proxy): revalidate proxy view when expanding a group
Badge values for freshly expanded rows seed from the cached proxy
view. Results that reach the core without a frontend event (tray
group test, provider health checks) are absent from that snapshot
until the next poll, so measured delays only appeared after another
interaction such as switching nodes forced a refetch. Route head
state patches through a wrapper that revalidates the view on
open transitions, matching how the group test already refreshes.
2026-10-16 06:44:54 +08:00
c6b51171a1 fix(backup): keep WebDAV password masked (#8291)
* fix(backup): keep WebDAV password masked

* fix(eslint): Clean unused imports

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-14 16:52:11 +00:00
Tunglies d4287970bc perf(tray): cache speed-text attributes and derive width arithmetically
Every traffic refresh rebuilt the attributed-string dictionary (font,
color, paragraph style, baseline number, dict) and ran a CoreText layout
via NSAttributedString::size() just to size the status item, although
both depend only on the button height and the glyph advance.

The dictionary and the monospaced per-glyph advance (measured once
through the same layout engine with a "0" probe) are now cached keyed by
button height, and the width is computed as longest-line length times
the advance. The speed charset is pure ASCII on a monospaced font, so
the arithmetic width matched the layout width exactly (max deviation
0.000pt over 2000 realistic samples).

Standalone A/B over 43,200 updates (12h at 1 Hz), same hot path:

| variant | mean | p95 | phys_footprint after |
| --- | ---: | ---: | ---: |
| rebuild + layout (old) | 6.89us | 7.8us | 19.8MB |
| cached attrs | 4.71us | 5.0us | 7.8MB |
| cached + arithmetic width | 0.27us | 0.3us | 6.0MB |

The retained-memory delta is malloc-zone fragmentation from the churn,
which this removes at the source.
2026-10-14 12:01:44 +08:00
Tunglies b20fdc9b96 perf(runtime): cap tokio workers at 4
The cap of 8 was a conservative pick from 8ccb0b9a; that commit's own
setup benchmark had the 4-worker / 8-blocking row as the best config:
639.3ms avg / 645.5ms max setup time and 22.1 avg threads, ahead of the
adopted 8-worker row (650.1ms / 662.4ms, 26.0 threads).

Startup parallelism is unaffected in practice: the setup join! polls
cooperatively on one task, and CPU work (validation, script eval, YAML
parse) runs on the spawn_blocking pool, which keeps its 8-thread cap
through the existing 2x invariant. A 12h live session showed the 8 idle
workers accumulating under 0.5s CPU total, so four parked stacks
(~0.4MB dirty) are pure overhead.
2026-10-13 21:35:03 +08:00
Tunglies 5b10331022 feat!: remove signal crate for macOS and windows (#7827)
* feat!: remove signal crate

* fix: clippy

* feat(signal): limit the shutdown signal crate to Linux

Upstream now covers termination and session-end events on Windows and
macOS, but Linux has no equivalent, so pre-shutdown cleanup there still
depends on listening for SIGTERM/SIGINT/SIGHUP (#7827). Restore
clash-verge-signal behind a target_os = "linux" gate instead of dropping
it: the dependency lives in a linux-only target section, the crate root
carries an English Linux-only rationale and compiles to nothing
elsewhere, and the Windows backend is gone.

register() now takes the quit flow's unit future: the latch serializes
in-flight cleanups and releases after each attempt, leaving the
exit-or-cancel decision to the quit flow itself.

Verified with cargo check and clippy for aarch64-unknown-linux-gnu, and
cargo check on the host where the crate compiles empty.
2026-10-13 12:33:22 +00:00
Tunglies 3ec858eb9f fix(packaging): stop shipping frontend-contract in Linux bundles
tauri-cli's get_binaries() adds every file in src-tauri/src/bin/ as a
bundle binary, and the deb/rpm packagers copy each one to /usr/bin, so
the dev-only bindings generator ended up as /usr/bin/frontend-contract
(1.0 MB) in release packages and broke downstream repackaging (Copr:
"Installed but unpackaged file(s) found"). There is no bundler option
to exclude a binary, so move the generator to an example target, which
cargo keeps dev-only and get_binaries() never scans, and invoke it via
'cargo run --example'.

Ref https://github.com/clash-verge-rev/clash-verge-rev/pull/8131#issuecomment-6137907930
2026-10-13 16:20:13 +08:00
Huanhuan Liand❤是纱雾酱哟~ 3693efcf6c fix(tests): reset test item state when delay test fails (#8231)
* fix(tests): reset test item state when delay test fails

* docs(changelog): note test-item stuck-state fix

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-12 10:25:58 +00:00
142 changed files with 1896 additions and 2991 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.11.2"
"version": "24.22.1",
"pnpmVersion": "12.15.0"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0",
"version": "1.99.1",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+33
View File
@@ -0,0 +1,33 @@
# 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,6 +10,8 @@ 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 填写一个简洁明了的标题,以便他人快速检索
@@ -19,6 +21,8 @@ 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,12 +9,16 @@ 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
+7 -1
View File
@@ -1,6 +1,12 @@
## Problem
What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
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.
## Approach
+10 -10
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- 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.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -185,7 +185,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -282,7 +282,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || 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.21.0'
node-version: '26.12.1'
cache: 'pnpm'
- name: Pnpm Cache
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || 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.21.0'
node-version: '26.12.1'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
+80
View File
@@ -0,0 +1,80 @@
name: Contract Parity
on:
pull_request:
paths:
- "src-tauri/**/*.rs"
- "crates/**/*.rs"
- "Cargo.toml"
- "Cargo.lock"
- "src-tauri/Cargo.toml"
- "crates/**/Cargo.toml"
- "rust-toolchain.toml"
- "src-tauri/tauri.conf*.json"
- "src/services/bindings.ts"
- "scripts/gen-contract.mjs"
- "scripts/check-contract-changes.mjs"
- "biome.json"
- "biome.jsonc"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "scripts/contract-inputs.json"
- ".github/workflows/contract-parity.yml"
- ".husky/pre-push"
workflow_dispatch:
permissions:
contents: read
env:
HUSKY: 0
jobs:
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: '26.12.1'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Provision sidecar placeholders for tauri-build
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
run: pnpm gen:contract --check
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- 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.0
uses: dtolnay/rust-toolchain@1.99.1
- 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.21.0'
node-version: '26.12.1'
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.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
+14 -104
View File
@@ -2,11 +2,23 @@ name: Frontend Check
on:
pull_request:
paths:
- "src/**"
- "**/*.js"
- "**/*.ts"
- "**/*.tsx"
- "**/*.css"
- "**/*.scss"
- "**/*.json"
- "**/*.md"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "vite.config.*"
- ".github/workflows/frontend-check.yml"
workflow_dispatch:
permissions:
contents: read
pull-requests: read
env:
HUSKY: 0
@@ -17,46 +29,17 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check frontend changes
id: check_frontend
uses: dorny/paths-filter@v4
with:
filters: |
frontend:
- 'src/**'
- '**/*.js'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '**/*.json'
- '**/*.md'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'knip.json'
- 'eslint.config.ts'
- 'tsconfig.json'
- 'vite.config.*'
- name: Skip if no frontend changes
if: steps.check_frontend.outputs.frontend != 'true'
run: echo "No frontend changes, skipping frontend checks."
- name: Install pnpm
if: steps.check_frontend.outputs.frontend == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.21.0'
node-version: '26.12.1'
cache: 'pnpm'
- name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
uses: actions/cache@v6
with:
path: ~/.pnpm-store
@@ -65,94 +48,21 @@ jobs:
pnpm-shared-stable-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
if: steps.check_frontend.outputs.frontend == 'true'
- name: Run Prettier check
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm format:check
- name: Run ESLint
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm lint
- name: Run TypeScript typecheck
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck
- name: Run tests
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm test
- name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: scripts/contract-inputs.json
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true'
run: pnpm gen:contract --check
+8 -23
View File
@@ -2,6 +2,13 @@ name: Clippy Lint
on:
pull_request:
paths:
- "src-tauri/**"
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch:
env:
HUSKY: 0
@@ -23,28 +30,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check src-tauri changes
if: github.event_name != 'workflow_dispatch'
id: check_changes
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- name: Skip if src-tauri not changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust != 'true'
run: echo "No src-tauri changes, skipping clippy lint."
- name: Continue if src-tauri changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust == 'true'
run: echo "src-tauri changed, running clippy lint."
- name: Manual trigger - always run
if: github.event_name == 'workflow_dispatch'
run: |
echo "Manual trigger detected: skipping changes check and running clippy."
- name: Checkout Repository
uses: actions/checkout@v7
@@ -79,7 +64,7 @@ jobs:
run: cargo clippy-all
- name: Run Windows security tests
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
if: matrix.os == 'windows-latest'
working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }}
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -230,7 +230,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- 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.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- 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.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -466,7 +466,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- 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.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || 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.21.0'
node-version: '26.12.1'
- 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.21.0'
node-version: '26.12.1'
- 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.21.0'
node-version: '26.12.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+4 -15
View File
@@ -6,6 +6,10 @@ name: Check Formatting
on:
pull_request:
paths:
- "src-tauri/**"
- "**/*.rs"
- ".github/workflows/rustfmt.yml"
permissions:
contents: read
@@ -19,25 +23,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check Rust changes
id: check_rust
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- '**/*.rs'
- name: Skip if no Rust changes
if: steps.check_rust.outputs.rust != 'true'
run: echo "No Rust changes, skipping rustfmt."
- name: install Rust stable and rustfmt
if: steps.check_rust.outputs.rust == 'true'
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.12.1'
- uses: pnpm/action-setup@v6
name: Install pnpm
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.11.2
rust 1.99.0
nodejs 24.22.1
pnpm 12.15.0
rust 1.99.1
+25 -21
View File
@@ -18,22 +18,25 @@ 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.** Non-trivial changes require a pre-existing issue describing
the problem. If none exists, ask the maintainers to open or approve one
before implementing.
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.
2. **Scope discipline.** Every changed file must be justifiable from the linked
issue. No drive-by refactors, renames, formatting churn, or dependency bumps
unrelated to the problem being fixed.
issue or the stated PR scope. No drive-by refactors, renames, formatting
churn, or dependency bumps unrelated to the work.
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 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 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.
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.
@@ -44,13 +47,12 @@ 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 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.
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.
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
@@ -59,8 +61,10 @@ the ownership evidence the AI-slop review evaluates (see
[`template/Changelog.md`](template/Changelog.md): one line per
user-visible change, no internals.
## Pull Request Shape
## Issue and PR Workflows
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).
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.
Generated
+107 -191
View File
@@ -1119,7 +1119,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify 0.3.0",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
@@ -1138,7 +1138,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
"sha2 0.11.0",
"sha2 0.11.1",
"smartstring",
"socket2",
"specta",
@@ -1175,7 +1175,7 @@ dependencies = [
"windows-service",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip 9.0.1",
"zip 9.0.4",
]
[[package]]
@@ -1247,7 +1247,7 @@ dependencies = [
"once_cell",
"serde",
"serde_json",
"sha2 0.11.0",
"sha2 0.11.1",
"strum",
"strum_macros",
"sysproxy",
@@ -1462,9 +1462,12 @@ dependencies = [
[[package]]
name = "constant_time_eq"
version = "0.4.2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9"
dependencies = [
"rustversion",
]
[[package]]
name = "cookie"
@@ -3951,9 +3954,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.190"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libdbus-sys"
@@ -4048,11 +4051,11 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.21.0"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
dependencies = [
"sha2 0.11.0",
"sha2 0.11.1",
]
[[package]]
@@ -4167,12 +4170,6 @@ 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"
@@ -5343,9 +5340,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
[[package]]
name = "ppv-lite86"
@@ -5996,12 +5993,13 @@ dependencies = [
[[package]]
name = "rust-ini"
version = "0.21.3"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f"
dependencies = [
"cfg-if",
"ordered-multimap",
"trim-in-place",
]
[[package]]
@@ -6067,9 +6065,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
@@ -6410,9 +6408,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -6597,9 +6595,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -6622,7 +6620,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
@@ -6642,17 +6639,6 @@ 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"
@@ -6663,16 +6649,6 @@ 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"
@@ -6878,6 +6854,12 @@ 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"
@@ -6937,9 +6919,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e"
checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9"
dependencies = [
"base64 0.21.7",
"serde",
@@ -7186,9 +7168,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.12.1"
version = "2.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed99ee9694a2deb776d91cae48ac7411ddfc89ecae2f9b5041111d8c88f2ace9"
checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
dependencies = [
"anyhow",
"bytes",
@@ -7234,16 +7216,16 @@ dependencies = [
"tray-icon",
"url",
"webkit2gtk",
"webview2-com 0.39.1",
"webview2-com",
"window-vibrancy",
"windows 0.62.2",
]
[[package]]
name = "tauri-build"
version = "2.7.1"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
dependencies = [
"anyhow",
"cargo_toml",
@@ -7255,6 +7237,8 @@ dependencies = [
"semver 1.0.28",
"serde",
"serde_json",
"static_vcruntime",
"strsim",
"tauri-utils",
"tauri-winres",
"walkdir",
@@ -7262,11 +7246,11 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.7.1"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e50213e463086ecc283d7e9f51c30cc0fd304ad457425158018be26dbf0d0a80"
checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"brotli",
"ico",
"json-patch",
@@ -7289,9 +7273,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.7.1"
version = "2.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8129fb43bb6e0d6b73ad3315cd172beaab17cd28899c594b1b32c80edbbfd99c"
checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -7347,9 +7331,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.4.1"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "333d3b6beeb1c2c7fc040233d4ea7b6c03a9f8f039592b7400833c43fb5b568a"
checksum = "9be5ed8d94c33afe8377e885119069f6508780659271d0a25cfc54d441f2bc81"
dependencies = [
"arboard",
"log",
@@ -7362,9 +7346,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-deep-link"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ac7a92a46ab5c88f44532ca50906d6e448a948d4ddf8c5376ab125e3260f736"
checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4"
dependencies = [
"dunce",
"plist",
@@ -7410,9 +7394,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.1"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf9a5c92e39bdd84f22f6be9230e39d0c4e140345f71ffe6058dca4f71127cf"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b"
dependencies = [
"log",
"raw-window-handle",
@@ -7428,9 +7412,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.6.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb"
checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"
dependencies = [
"anyhow",
"dunce",
@@ -7452,9 +7436,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.4.0"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bd2e1f725891a1613af8f25bf918ba3357654b0fd3f2d794c0ce76cbdd92ed2"
checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d"
dependencies = [
"global-hotkey",
"log",
@@ -7467,9 +7451,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.8.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e9d6b3319e101bc1be27cfa0fb4054d7784a12509c67ead0033b82180e5ed1"
checksum = "2c57e3451553f95a1db3bfed6a358a77ce5080b6399a242c6eadac113f97abd2"
dependencies = [
"bytes",
"cookie_store",
@@ -7486,13 +7470,13 @@ dependencies = [
"thiserror 2.0.20",
"tokio",
"url",
"urlpattern 0.3.0",
"urlpattern",
]
[[package]]
name = "tauri-plugin-mihomo"
version = "0.6.0"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?rev=7a03d16f4d39b1bb885b7babe66b9e153e34b615#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
dependencies = [
"arc-swap",
"clashmap",
@@ -7516,9 +7500,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-notification"
version = "2.5.1"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533770889abb60a0407d5a32096182f0e6463f5a3dfaa675be94580aa14070e1"
checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b"
dependencies = [
"log",
"notify-rust",
@@ -7531,13 +7515,14 @@ dependencies = [
"thiserror 2.0.20",
"time",
"url",
"zbus",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.7.0"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39404b2572927e0d3ed30a7ca226037f7508d22e79c96ee6ef0d42b6a4b3345"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
dependencies = [
"dunce",
"glob",
@@ -7567,11 +7552,12 @@ dependencies = [
[[package]]
name = "tauri-plugin-shell"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "094bdbc47e5efe36544ba21b3a53ab5c724150236cf55e6c9be46191deb6eab6"
checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521"
dependencies = [
"encoding_rs",
"libc",
"log",
"open",
"os_pipe",
@@ -7584,13 +7570,14 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.20",
"tokio",
"windows-sys 0.60.2",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.13.1"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cb0b2ea3e85ca287990d3859a29cc5cb18024240fd78f62e027fb7963670f18"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec"
dependencies = [
"base64 0.22.1",
"dirs 7.0.0",
@@ -7598,8 +7585,9 @@ dependencies = [
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify 0.2.5",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.5",
@@ -7621,9 +7609,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-wdio-webdriver"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c5e97428174a52b7f4357bbc4535fcce52c6a06c8ee7a379502f7cda1b6045"
checksum = "4f76676d510d271d80abcf8b0552987b5b4f0eae5e3f906e9b5660d43c97c62e"
dependencies = [
"async-trait",
"axum",
@@ -7647,9 +7635,9 @@ dependencies = [
"tracing",
"uuid",
"webkit2gtk",
"webview2-com 0.38.2",
"windows 0.61.3",
"windows-core 0.61.2",
"webview2-com",
"windows 0.62.2",
"windows-core 0.62.2",
]
[[package]]
@@ -7669,9 +7657,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.12.1"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9"
dependencies = [
"cookie",
"dpi",
@@ -7688,15 +7676,15 @@ dependencies = [
"thiserror 2.0.20",
"url",
"webkit2gtk",
"webview2-com 0.39.1",
"webview2-com",
"windows 0.62.2",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.12.1"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
dependencies = [
"gtk",
"http",
@@ -7714,7 +7702,7 @@ dependencies = [
"tracing",
"url",
"webkit2gtk",
"webview2-com 0.39.1",
"webview2-com",
"windows 0.62.2",
"wry",
]
@@ -7752,9 +7740,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.10.1"
version = "2.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3"
dependencies = [
"anyhow",
"brotli",
@@ -7783,7 +7771,7 @@ dependencies = [
"thiserror 2.0.20",
"toml 1.1.6+spec-1.1.0",
"url",
"urlpattern 0.6.0",
"urlpattern",
"uuid",
"walkdir",
]
@@ -7902,7 +7890,7 @@ dependencies = [
"regex",
"semver 0.11.0",
"sha2 0.9.9",
"signal-hook 0.1.17",
"signal-hook",
"terminfo",
"termios",
"thiserror 1.0.69",
@@ -8073,9 +8061,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.53.2"
version = "1.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
@@ -8135,9 +8123,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.19"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
@@ -8500,6 +8488,12 @@ 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"
@@ -8592,47 +8586,6 @@ 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"
@@ -8692,18 +8645,6 @@ 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"
@@ -9095,20 +9036,6 @@ 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"
@@ -9116,7 +9043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
dependencies = [
"webview2-com-macros",
"webview2-com-sys 0.39.1",
"webview2-com-sys",
"windows 0.62.2",
"windows-core 0.62.2",
]
@@ -9132,17 +9059,6 @@ 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"
@@ -9882,7 +9798,7 @@ dependencies = [
"url",
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com 0.39.1",
"webview2-com",
"windows 0.62.2",
"windows-core 0.62.2",
"windows-version",
@@ -10150,9 +10066,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.1"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"aes",
"bzip2",
@@ -10202,27 +10118,27 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.13.3"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
version = "2.1.2+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
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.1" }
tauri-plugin-clipboard-manager = "2.4.1"
tauri = { version = "2.12.4" }
tauri-plugin-clipboard-manager = "2.5.0"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.2", features = [
tokio = { version = "1.53.3", 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.151" }
serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
socket2 = "0.6.5"
+15 -1
View File
@@ -3,19 +3,33 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
-2
View File
@@ -70,8 +70,6 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
-2
View File
@@ -70,8 +70,6 @@ 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,8 +70,6 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
-2
View File
@@ -70,8 +70,6 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
-2
View File
@@ -70,8 +70,6 @@ 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,8 +70,6 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
-2
View File
@@ -70,8 +70,6 @@ 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,8 +70,6 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
@@ -11,6 +11,7 @@ mod chatgpt;
mod claude;
mod disney_plus;
mod gemini;
mod muse;
mod netflix;
mod prime_video;
mod spotify;
@@ -30,6 +31,7 @@ enum UnlockCheck {
ChatGptWeb,
Claude,
Gemini,
MuseAi,
YouTubePremium,
BahamutAnime,
Netflix,
@@ -46,6 +48,7 @@ impl UnlockCheck {
Self::ChatGptWeb,
Self::Claude,
Self::Gemini,
Self::MuseAi,
Self::YouTubePremium,
Self::BahamutAnime,
Self::Netflix,
@@ -66,6 +69,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
Self::Claude => claude::CLAUDE_NAME,
Self::Gemini => gemini::GEMINI_NAME,
Self::MuseAi => muse::MUSE_AI_NAME,
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
Self::Netflix => netflix::NETFLIX_NAME,
@@ -83,6 +87,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
Self::Claude => claude::check_claude(client).await,
Self::Gemini => gemini::check_gemini(client).await,
Self::MuseAi => muse::check_muse_ai(client).await,
Self::YouTubePremium => youtube::check_youtube_premium(client).await,
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
Self::Netflix => netflix::check_netflix(client).await,
@@ -0,0 +1,48 @@
use reqwest::Client;
use crate::utils::{classify_restricted_status, get_trace_location};
use super::UnlockItem;
pub(crate) const MUSE_AI_NAME: &str = "Muse AI";
const MUSE_URL: &str = "https://muse.ai";
const CLOUDFLARE_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
// The aymh ("Are You a Human") gateway answers plain HTTP requests with a
// generic Meta error page that always contains the Facebook logo asset.
const AYMH_ERROR_MARKERS: &[&str] = &["Sorry, something went wrong", "static.facebook.com/images/logos"];
pub(super) async fn check_muse_ai(client: &Client) -> UnlockItem {
// muse.ai itself is served by Vercel, so resolve the exit region through
// the generic Cloudflare trace endpoint instead of a service-specific one.
let region = get_trace_location(client, CLOUDFLARE_TRACE_URL)
.await
.map(|code| code.trim().to_ascii_uppercase())
.map(|code| UnlockItem::region_label(&code));
let Ok(response) = client.get(MUSE_URL).send().await else {
return UnlockItem::checked(MUSE_AI_NAME, "Failed (Network Connection)", region);
};
let status = response.status();
let landed_on_aymh = response.url().host_str() == Some("auth.muse.ai");
let body = response.text().await.unwrap_or_default();
// Every region is redirected to the aymh anti-bot gateway before the app
// loads, and a non-browser HTTP client cannot finish the JavaScript
// challenge. It returns the Meta error page, so reaching this point only
// proves the exit can connect to Muse's auth infrastructure; availability
// cannot be verified over plain HTTP and is reported as `Risky`.
let behind_anti_bot = landed_on_aymh || AYMH_ERROR_MARKERS.iter().any(|marker| body.contains(marker));
let result = if behind_anti_bot {
"Risky"
} else if status.is_success() {
"Yes"
} else {
classify_restricted_status(status).unwrap_or("Failed")
};
UnlockItem::checked(MUSE_AI_NAME, result, region)
}
+23 -31
View File
@@ -1,3 +1,15 @@
//! 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},
@@ -5,20 +17,13 @@ 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();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
/// Serializes signal-triggered cleanups: a signal arriving while a quit
/// attempt is still in flight is dropped instead of overlapping it.
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
@@ -36,17 +41,18 @@ impl ShutdownLatch {
.is_ok()
}
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
fn finish(&self) {
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 = ShutdownOutcome> + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -61,36 +67,22 @@ where
}
});
#[cfg(unix)]
unix::register(f);
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::{ShutdownLatch, ShutdownOutcome};
use super::ShutdownLatch;
#[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
fn finished_attempt_releases_latch_for_the_next_signal() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled);
latch.finish();
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());
}
}
+4 -3
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
use crate::{RUNTIME, SHUTDOWN_LATCH};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -60,7 +60,8 @@ where
}
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
f().await;
SHUTDOWN_LATCH.finish();
}
});
} else {
-83
View File
@@ -1,83 +0,0 @@
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.190"
libc = "0.2.191"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
+18 -17
View File
@@ -14,7 +14,7 @@
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
"web:build": "tsrs -p . --noEmit && vite build",
"web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs",
@@ -32,7 +32,7 @@
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"typecheck": "tsrs -p . --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"knip:check": "knip",
@@ -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.1",
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.13.1",
"@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",
"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.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react-i18next": "17.1.8",
"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.1",
"@tauri-apps/cli": "2.12.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
@@ -103,7 +103,7 @@
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"@wdio/tauri-service": "1.5.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"cli-color": "^2.0.4",
@@ -123,17 +123,18 @@
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"knip": "6.43.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"tsrs": "0.20.0-ts7.1.0-beta",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"webdriverio": "10.1.0",
"ws": "8.22.0"
},
"lint-staged": {
@@ -146,5 +147,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.11.2"
"packageManager": "pnpm@12.15.0"
}
+612 -727
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -10,3 +10,11 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- "@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta"
- "@tsrs/darwin-x64@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/win32-x64@0.20.0-ts7.1.0-beta"
- tsrs@0.20.0-ts7.1.0-beta
+18
View File
@@ -45,6 +45,17 @@
"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"],
@@ -68,6 +79,13 @@
"matchPackageNames": ["typescript"],
"allowedVersions": "<7.0.0 || >=7.1.0"
},
{
"description": "Review tsrs bumps alone; the version encodes the pinned TypeScript release",
"matchPackageNames": ["tsrs", "/^@tsrs//"],
"groupName": "tsrs",
"rangeStrategy": "pin",
"automerge": false
},
{
"description": "Prevent project from supply chain attack",
"matchDatasources": ["npm"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.0"
channel = "1.99.1"
components = ["rustfmt", "clippy"]
+1 -1
View File
@@ -17,7 +17,7 @@
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/frontend-check.yml",
".github/workflows/contract-parity.yml",
".husky/pre-push"
]
}
+1 -1
View File
@@ -22,7 +22,7 @@ try {
'run',
'-p',
'clash-verge',
'--bin',
'--example',
'frontend-contract',
'--locked',
'-q',
+5 -4
View File
@@ -434,12 +434,13 @@ for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
devicePixelRatio,
])
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
const driverScale = run.viewport.driver.width / 1000
assert(
run.viewport.driver.width / viewport[2] === 1000 &&
run.viewport.driver.height / viewport[2] === 700 &&
viewport[0] === 1000 &&
viewport[0] === 1000 &&
viewport[1] > 0 &&
viewport[1] <= 700,
viewport[1] <= 700 &&
(driverScale === 1 || driverScale === viewport[2]) &&
run.viewport.driver.height / driverScale === 700,
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
)
assert(
+21 -18
View File
@@ -29,13 +29,12 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.7.1", features = [] }
tauri-build = { version = "2.7.3", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -73,51 +72,55 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-shell = "2.5.0"
tauri-plugin-opener = "2.7.2"
tauri-plugin-dialog = "2.8.3"
tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.1"
zip = "9.0.4"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11"
sha2 = "0.11.1"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.5.1"
tauri-plugin-notification = "2.7.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.0"
tauri-plugin-http = "2.8.3"
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", branch = "main" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.45", features = ["ring"] }
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
tokio-util = { version = "0.7.20", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
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 }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
@@ -150,8 +153,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.0"
tauri-plugin-updater = "2.13.1"
tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.0"
[dev-dependencies]
criterion = { workspace = true }
+10 -5
View File
@@ -1,10 +1,9 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
{
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
// Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses or examples.
// Give non-app targets their own manifest so tests and examples can resolve TaskDialogIndirect.
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
std::fs::write(
@@ -25,8 +24,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
</assembly>
"#,
)?;
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
let manifest_display = manifest_path.display();
if std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() {
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{manifest_display}");
} else {
println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-examples=/MANIFESTINPUT:{manifest_display}");
}
}
#[cfg(feature = "clippy")]
@@ -1,3 +1,5 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1);
let output = args
+6 -7
View File
@@ -282,7 +282,9 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
discard_and_restore(current_profile).await?;
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
if !matches!(outcome, ValidationOutcome::Busy) {
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
}
Ok(outcome)
}
@@ -311,9 +313,6 @@ 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 {
@@ -373,8 +372,6 @@ 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>,
@@ -389,7 +386,6 @@ 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,
@@ -409,6 +405,9 @@ 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 {
+2 -14
View File
@@ -108,32 +108,20 @@ 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(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
providers: providers.ok(),
}))
}
-6
View File
@@ -53,10 +53,6 @@ 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>,
@@ -233,7 +229,6 @@ impl PrfItem {
desc: Some(desc),
file: Some(file),
url: None,
latency_test_urls: None,
selected: None,
extra: None,
option: Some(PrfOption {
@@ -413,7 +408,6 @@ 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,7 +250,6 @@ 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);
+1
View File
@@ -75,6 +75,7 @@ pub mod tun {
"auto-redirect",
"auto-detect-interface",
"dns-hijack",
"dns-mode",
"strict-route",
"mtu",
];
+6 -97
View File
@@ -1,8 +1,8 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet};
use serde::{Serialize, Serializer, ser::SerializeMap as _};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
@@ -57,28 +57,6 @@ 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 {
@@ -98,9 +76,6 @@ 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>,
@@ -116,9 +91,6 @@ 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")]
@@ -284,7 +256,7 @@ impl ProxyViewBuilder {
} else {
ProxyViewProviderState::Unavailable
};
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut core_groups, core_nodes) = partition_core(proxies);
let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver {
@@ -317,14 +289,14 @@ impl ProxyViewBuilder {
}
}
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
fn partition_core(proxies: Proxies) -> (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 if !providers_available || proxy.provider_name.is_empty() {
} else {
nodes.insert(name, proxy);
}
}
@@ -431,7 +403,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
now,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -464,7 +435,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon,
test_url,
history,
extra,
capabilities: ProxyCapabilities {
udp,
xudp,
@@ -483,7 +453,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
icon,
test_url,
history,
extra,
udp,
xudp,
tfo,
@@ -498,7 +467,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name,
proxy_type,
history,
extra,
id,
hidden,
icon,
@@ -552,62 +520,3 @@ 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
@@ -1,156 +0,0 @@
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,7 +32,6 @@ 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",
+140 -362
View File
@@ -1,6 +1,5 @@
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;
@@ -15,6 +14,7 @@ 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,7 +28,6 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
mod latency;
mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
@@ -56,25 +55,8 @@ 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,
}
@@ -148,9 +130,6 @@ 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(),
}
@@ -216,7 +195,6 @@ 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(());
@@ -241,133 +219,29 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
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_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?,
))
);
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, "应用正在退出,跳过托盘图标更新");
@@ -548,12 +422,11 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
let mut parts = item.split(',');
match (parts.next(), parts.next()) {
(Some(func), Some(key)) => {
// 托盘菜单中的 `accelerator` 属性,在 Linux/Windows 中都不支持小键盘按键的解析
if key.to_uppercase().contains("NUMPAD") {
None
} else {
Some((func, key))
}
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`)
let uppercase_key = key.to_uppercase();
let unsupported =
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD"));
if unsupported { None } else { Some((func, key)) }
}
_ => None,
}
@@ -583,175 +456,96 @@ 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,
view: Option<ProxyViewV1>,
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
) -> Vec<Submenu<Wry>> {
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;
}
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,
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()
})
.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")
.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 {
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()
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
}
}
}
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
}
fn create_proxy_menu_item(
@@ -798,38 +592,54 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
) -> Result<tauri::menu::Menu<Wry>> {
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");
// 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)
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime = Config::runtime().await.latest_arc();
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else {
None
(None, 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");
@@ -909,17 +719,9 @@ 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_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -1063,25 +865,10 @@ async fn create_tray_menu(
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok((menu, latency_items))
Ok(menu)
}
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 { .. }
@@ -1203,15 +990,6 @@ 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,
+1 -3
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() -> clash_verge_signal::ShutdownOutcome {
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting();
@@ -165,13 +165,11 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
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,6 +230,7 @@ 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, 8);
let worker_limit = std::cmp::min(default_parallelism, 4);
let blocking_limit = 2 * worker_limit;
#[allow(clippy::unwrap_used)]
+2 -1
View File
@@ -20,7 +20,6 @@ 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;
@@ -189,6 +188,8 @@ 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);
+1 -52
View File
@@ -39,8 +39,6 @@ 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 {
@@ -164,55 +162,12 @@ 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.clone());
let auth = instance_auth(token);
#[cfg(not(feature = "verge-dev"))]
let auth = instance_auth(token);
@@ -278,12 +233,6 @@ 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)
+33 -11
View File
@@ -27,8 +27,12 @@ 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 {
@@ -74,8 +78,26 @@ fn create_attributed_string(
}
}
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)
/// 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 sync_click_target_frame(button: &NSStatusBarButton) {
@@ -89,7 +111,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: &NSString, show_speed: bool) {
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
return;
@@ -97,17 +119,19 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSStrin
let Some(button) = status_item.button(mtm) else {
return;
};
let ns_string = NSString::from_str(text);
let attr_str = if show_speed {
let attrs = build_attributes(button.bounds().size.height);
let (advance, attrs) = cached_attributes(button.bounds().size.height);
let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
let attr_str = create_attributed_string(text, Some(attrs));
let length = status_item_length_for_speed(&attr_str);
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);
if status_item.length() != length {
status_item.setLength(length);
}
attr_str
} else {
create_attributed_string(text, None)
create_attributed_string(&ns_string, None)
};
button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button);
@@ -121,8 +145,7 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return;
}
*last = speed_text;
let ns_string = NSString::from_str(last.as_str());
apply_status_item_attributed_title(status_item, &ns_string, true);
apply_status_item_attributed_title(status_item, last.as_str(), true);
});
}
@@ -130,7 +153,6 @@ 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, &empty, false);
apply_status_item_attributed_title(status_item, "", false);
}
@@ -47,10 +47,7 @@ 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,
@@ -678,20 +675,6 @@ 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],
@@ -924,12 +907,6 @@ 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(
+37 -9
View File
@@ -4,10 +4,19 @@ import {
VisibilityOffOutlined,
VisibilityOutlined,
} from '@mui/icons-material'
import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
import {
Box,
Button,
FormControlLabel,
IconButton,
Skeleton,
Switch,
Typography,
} from '@mui/material'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from 'foxact/use-abortable-effect'
import { useIntersection } from 'foxact/use-intersection'
import { useLocalStorage } from 'foxact/use-local-storage'
import type { XOR } from 'foxts/ts-xor'
import {
forwardRef,
@@ -99,9 +108,13 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
export const IpInfoCard = () => {
const { t } = useTranslation()
const [showIp, setShowIp] = useState(false)
const [autoRefresh, setAutoRefresh] = useLocalStorage(
'ip-info-auto-refresh',
true,
)
const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
// Once intersected, refreshes stay enabled until unmount.
// Once intersected, the card stays eligible for refresh until unmount.
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: '0px',
})
@@ -151,6 +164,8 @@ export const IpInfoCard = () => {
})
useEffect(() => {
if (!autoRefresh) return
let timer: number | null = null
// Prefer DOM intersection state; Tauri visibility is unreliable on some platforms.
@@ -190,7 +205,7 @@ export const IpInfoCard = () => {
if (timer != null) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [hasIntersected])
}, [autoRefresh, hasIntersected])
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev)
@@ -364,12 +379,25 @@ export const IpInfoCard = () => {
fontSize: '0.7rem',
}}
>
<Typography variant="caption">
{t('home.components.ipInfo.labels.autoRefresh')}
{countdown.type === 'countdown'
? `: ${countdown.remainingSeconds}s`
: '...'}
</Typography>
<FormControlLabel
sx={{ m: 0 }}
control={
<Switch
size="small"
checked={autoRefresh}
onChange={(_, checked) => setAutoRefresh(checked)}
/>
}
label={
<Typography variant="caption">
{t('home.components.ipInfo.labels.autoRefresh')}
{autoRefresh &&
(countdown.type === 'countdown'
? `: ${countdown.remainingSeconds}s`
: '...')}
</Typography>
}
/>
<Typography
variant="caption"
sx={{
@@ -397,9 +397,9 @@ export const RulesEditorViewer = (props: Props) => {
return
}
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
setDeleteSeq(obj?.delete || [])
setPrependSeq((obj?.prepend || []).filter((rule) => rule !== null))
setAppendSeq((obj?.append || []).filter((rule) => rule !== null))
setDeleteSeq((obj?.delete || []).filter((rule) => rule !== null))
hasLoadedSeqConfigRef.current = true
}, [property])
@@ -417,9 +417,9 @@ export const RulesEditorViewer = (props: Props) => {
hasLoadedSeqConfigRef.current = true
startTransition(() => {
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
setDeleteSeq(obj?.delete ?? [])
setPrependSeq((obj?.prepend ?? []).filter((rule) => rule !== null))
setAppendSeq((obj?.append ?? []).filter((rule) => rule !== null))
setDeleteSeq((obj?.delete ?? []).filter((rule) => rule !== null))
})
setVisualization(true)
}
+7
View File
@@ -75,6 +75,7 @@ interface ProxyChainProps {
onMarkUnsavedChanges?: () => void
mode?: string
selectedGroup?: string | null
onExitChainMode: () => void
}
interface ProxyChainItemProps {
@@ -331,6 +332,7 @@ export const ProxyChain = ({
onMarkUnsavedChanges,
mode,
selectedGroup,
onExitChainMode,
}: ProxyChainProps) => {
const theme = useTheme()
const { t } = useTranslation()
@@ -696,13 +698,18 @@ 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
+6 -11
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 } from '@mui/material'
import { Box, IconButton, type SxProps, TextField } from '@mui/material'
import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
@@ -18,11 +18,10 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidLatencyTestUrl } from '@/utils/network'
import { isValidUrl } 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'
@@ -70,10 +69,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -116,8 +112,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)}
{textState === 'url' && (
<ProxyTestUrlInput
name={groupName}
<TextField
inputRef={inputRef}
autoComplete="new-password"
hiddenLabel
@@ -130,7 +125,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault()
e.stopPropagation()
}}
onCommit={(testUrl) => onHeadState({ testUrl })}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -181,7 +176,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -77,6 +77,7 @@ interface GroupSelectMenuProps {
interface ProxyGroupsChainProps {
mode: string
chainConfigData?: string | null
onExitChainMode: () => void
availableGroups: any[]
activeSelectedGroup: string | null
showScrollTop: boolean
@@ -305,6 +306,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
const {
mode,
chainConfigData,
onExitChainMode,
availableGroups,
activeSelectedGroup,
showScrollTop,
@@ -521,6 +523,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
chainConfigData={chainConfigData}
mode={mode}
selectedGroup={activeSelectedGroup}
onExitChainMode={onExitChainMode}
/>
</Box>
</Box>
+25 -17
View File
@@ -23,9 +23,7 @@ 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,
@@ -44,6 +42,7 @@ import {
ProxyGroupNavigator,
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import {
PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout,
@@ -70,6 +69,7 @@ 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 } = useRenderList(
mode,
isChainMode,
activeSelectedGroup,
)
const {
renderList,
onProxies,
onHeadState: setListHeadState,
} = useRenderList(mode, isChainMode, activeSelectedGroup)
const scrollPositionKey = useMemo(
() =>
isChainMode
@@ -103,6 +103,14 @@ 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(
@@ -135,12 +143,6 @@ 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)
})
}
}),
)
@@ -190,8 +192,9 @@ function useProxyRenderState(
function ChainProxyGroups(props: {
mode: string
chainConfigData?: string | null
onExitChainMode: () => void
}) {
const { mode, chainConfigData } = props
const { mode, chainConfigData, onExitChainMode } = props
const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@@ -346,6 +349,7 @@ function ChainProxyGroups(props: {
<ProxyGroupsChain
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop}
@@ -604,7 +608,7 @@ function NormalProxyGroups(props: { mode: string }) {
/>
</DragDropProvider>
{mode === 'rule' && (
{(mode === 'rule' || mode === 'global') && (
<ProxyGroupNavigator
proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName}
@@ -617,7 +621,7 @@ function NormalProxyGroups(props: { mode: string }) {
}
export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData } = props
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData()
@@ -639,7 +643,11 @@ export const ProxyGroups = (props: Props) => {
return <ProxyEmptyState reason={listState.reason} />
case 'render':
return isChainMode ? (
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
<ChainProxyGroups
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
) : (
<NormalProxyGroups mode={mode} />
)
+6 -11
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded,
SortRounded,
} from '@mui/icons-material'
import { Box, IconButton, type SxProps } from '@mui/material'
import { Box, IconButton, TextField, type SxProps } from '@mui/material'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,9 +20,8 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import { isValidLatencyTestUrl } from '@/utils/network'
import { isValidUrl } from '@/utils/network'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -73,10 +72,7 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204'
useEffect(() => {
delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
return (
@@ -101,7 +97,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
@@ -192,8 +188,7 @@ export const ProxyHead = ({
)}
{textState === 'url' && (
<ProxyTestUrlInput
name={groupName}
<TextField
autoComplete="new-password"
autoFocus={autoFocus}
hiddenLabel
@@ -202,7 +197,7 @@ export const ProxyHead = ({
size="small"
variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onCommit={(testUrl) => onHeadState({ testUrl })}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/>
)}
@@ -1,55 +0,0 @@
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()
}
}}
/>
)
}
+4 -12
View File
@@ -13,17 +13,11 @@ 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 unknown as ResolvedMemberOccurrence
}) as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks())
@@ -42,9 +36,7 @@ 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(list[i].member, group, delay),
)
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay))
}
for (const timeout of [10000, 20, 0, NaN]) {
const expected = list
@@ -74,7 +66,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(list[0].member, 'expiry', 1)
delayManager.setDelay('0', 'expiry', 1)
const get = vi.spyOn(delayManager, 'getDelayFix')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
expect(get).toHaveBeenCalledTimes(list.length)
@@ -88,7 +80,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
list[3],
list[0],
])
delayManager.setDelay(list[0].member, 'expiry', 2)
delayManager.setDelay('0', 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear()
filterSort([], 'expiry', '', 1)
+18 -60
View File
@@ -1,10 +1,6 @@
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'
@@ -36,45 +32,22 @@ export const DEFAULT_STATE: HeadState = {
}
type HeadStateAction =
| { type: 'reset'; profile: string }
| { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'reset' }
| { type: 'replace'; 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: LoadedHeadState,
state: Record<string, HeadState>,
action: HeadStateAction,
): LoadedHeadState {
): Record<string, HeadState> {
switch (action.type) {
case 'reset':
return { profile: action.profile, heads: {} }
return {}
case 'replace':
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 }
}
return action.payload
case 'update': {
const prev = state.heads[action.groupName] || DEFAULT_STATE
return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
const prev = state[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } }
}
default:
return state
@@ -82,42 +55,30 @@ function headStateReducer(
}
export function useHeadStateNew() {
const { profiles, current: profile } = useProfiles()
const { profiles } = useProfiles()
const current = profiles?.current || ''
const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
const [state, dispatch] = useReducer(headStateReducer, {})
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] || {}
for (const [name, testUrl] of Object.entries(urls)) {
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
}
const value = data[current] || {}
if (value && typeof value === 'object') {
dispatch({ type: 'replace', profile: current, payload: value })
dispatch({ type: 'replace', payload: value })
} else {
dispatch({ type: 'reset', profile: current })
dispatch({ type: 'reset' })
}
} catch {
dispatch({ type: 'reset', profile: current })
dispatch({ type: 'reset' })
}
}, [current, loadedProfile, profile])
}, [current])
useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => {
try {
const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -133,16 +94,13 @@ export function useHeadStateNew() {
})
return () => clearTimeout(timer)
}, [state, current, loadedProfile])
}, [state, current])
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
+2 -3
View File
@@ -116,7 +116,6 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false,
smux: false,
history: [],
extra: {},
members,
})
@@ -212,7 +211,7 @@ export const useRenderList = (
if (!proxyView) return []
if (isChainMode)
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
return mode === 'rule' || mode === 'script'
return mode === 'rule' || mode === 'global'
? proxyView.groups.map(({ name }) => name)
: proxyView.global
? [proxyView.global.name]
@@ -258,7 +257,7 @@ export const useRenderList = (
}))
}
const useRule = mode === 'rule' || mode === 'script'
const useRule = mode === 'rule' || mode === 'global'
const renderGroups = useRule
? proxyView.groups
: proxyView.global === null
@@ -1,20 +1,11 @@
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 { Button, Grid, Stack, TextField } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useState, useRef, memo, useEffect } from 'react'
import { memo, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
@@ -43,7 +34,6 @@ 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)
@@ -68,10 +58,6 @@ 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
@@ -215,7 +201,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}>
<TextField
label={t('shared.labels.password')}
type={showPassword ? 'text' : 'password'}
type="password"
variant="outlined"
size="small"
autoCorrect="off"
@@ -223,20 +209,6 @@ 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>
@@ -0,0 +1,43 @@
import { Button, ButtonGroup } from '@mui/material'
interface Props {
value?: string
onChange?: (value: string) => void
}
export const DnsModeSwitch = (props: Props) => {
const { value, onChange } = props
return (
<ButtonGroup size="small" sx={{ my: '4px' }}>
<Button
variant={!value ? 'contained' : 'outlined'}
onClick={() => onChange?.('')}
sx={{ textTransform: 'capitalize' }}
>
Auto
</Button>
<Button
variant={value === 'disabled' ? 'contained' : 'outlined'}
onClick={() => onChange?.('disabled')}
sx={{ textTransform: 'capitalize' }}
>
Disabled
</Button>
<Button
variant={value === 'native' ? 'contained' : 'outlined'}
onClick={() => onChange?.('native')}
sx={{ textTransform: 'capitalize' }}
>
Native
</Button>
<Button
variant={value === 'hijack' ? 'contained' : 'outlined'}
onClick={() => onChange?.('hijack')}
sx={{ textTransform: 'capitalize' }}
>
Hijack
</Button>
</ButtonGroup>
)
}
@@ -26,6 +26,7 @@ import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
import { areValidIpCidrs } from '@/utils/network'
import { DnsModeSwitch } from './dns-mode-switch'
import { StackModeSwitch } from './stack-mode-switch'
const OS = getSystem()
@@ -52,6 +53,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: ['any:53'],
strictRoute: false,
mtu: 1500,
dnsMode: '',
})
const routeExcludeAddressItems = splitRouteExcludeAddress(
@@ -84,6 +86,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: clash?.tun['dns-hijack'] ?? ['any:53'],
strictRoute: clash?.tun['strict-route'] ?? false,
mtu: clash?.tun.mtu ?? 1500,
dnsMode: clash?.tun['dns-mode'] ?? '',
})
},
close: () => setOpen(false),
@@ -117,6 +120,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
: {}),
'auto-detect-interface': values.autoDetectInterface,
'dns-hijack': values.dnsHijack[0] === '' ? [] : values.dnsHijack,
'dns-mode': values.dnsMode,
'strict-route': values.strictRoute,
mtu: values.mtu ?? 1500,
}
@@ -163,6 +167,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
'auto-detect-interface': true,
'dns-hijack': ['any:53'],
'route-exclude-address': [],
'dns-mode': '',
'strict-route': false,
mtu: 1500,
}
@@ -176,6 +181,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
dnsHijack: ['any:53'],
strictRoute: false,
mtu: 1500,
dnsMode: '',
})
await patchClash({ tun })
await mutateClash(
@@ -212,6 +218,25 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText
primary={t('settings.modals.tun.fields.dnsMode')}
sx={{ maxWidth: 'fit-content' }}
/>
<TooltipIcon
title={t('settings.modals.tun.tooltips.dnsMode')}
sx={{ opacity: 0.7 }}
/>
<Box sx={{ marginLeft: 'auto' }}>
<DnsModeSwitch
value={values.dnsMode}
onChange={(value) => {
setValues((v) => ({ ...v, dnsMode: value }))
}}
/>
</Box>
</ListItem>
<ListItem sx={{ padding: '5px 2px' }}>
<ListItemText primary={t('settings.modals.tun.fields.device')} />
<TextField
+9 -3
View File
@@ -1,5 +1,5 @@
import { LanguageRounded } from '@mui/icons-material'
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
import { alpha, Box, Divider, Menu, MenuItem, styled } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -29,8 +29,14 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
const onDelay = useCallback(async () => {
setDelay(-2)
const result = await cmdTestDelay(url!)
setDelay(result)
try {
const result = await cmdTestDelay(url!)
setDelay(result)
} catch (err) {
// A failed test must not leave the item stuck in the loading state.
setDelay(-1)
showNotice.error(err)
}
}, [url])
const onEditTest = () => {
+3 -11
View File
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, 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'
@@ -36,10 +35,6 @@ export const useProfiles = () => {
refetchInterval: false,
})
useEffect(() => {
if (profiles) migrateProfileTestUrls(profiles)
}, [profiles])
const refetchRef = useRef(refetch)
refetchRef.current = refetch
const mutateProfiles = useCallback(async () => {
@@ -51,6 +46,7 @@ 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) =>
@@ -60,12 +56,8 @@ 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' && outcome.status !== 'busy') {
if (outcome.status !== 'valid') {
await mutateProfiles()
}
return outcome
+9 -12
View File
@@ -34,6 +34,7 @@ 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)
@@ -42,18 +43,18 @@ export function useProxyDelayState(
useEffect(() => {
if (isPreset) return
delayManager.setListener(member, groupName, setDelayState)
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(member, groupName)
delayManager.removeListener(name, groupName)
}
}, [member, groupName, isPreset])
}, [name, groupName, isPreset])
const updateDelay = useCallback(() => {
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(member, groupName)
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
@@ -66,9 +67,7 @@ export function useProxyDelayState(
}
let updatedAt = 0
const history =
delayManager.getHistory(member, groupName) ??
memberDetails(member)?.history
const history = details?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
@@ -78,18 +77,16 @@ export function useProxyDelayState(
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [groupName, member, unresolved])
}, [details?.history, groupName, member, name, unresolved])
useEffect(() => {
updateDelay()
return delayManager.addGroupListener(groupName, updateDelay)
}, [groupName, updateDelay])
}, [updateDelay])
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() })
await delayManager.checkDelay(member, groupName, timeout)
updateDelay()
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
})
return {
+2
View File
@@ -70,6 +70,8 @@
"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
@@ -495,11 +495,13 @@
"strictRoute": "توجيه صارم",
"autoDetectInterface": "الكشف التلقائي عن الواجهة",
"dnsHijack": "اختطاف DNS",
"dnsMode": "وضع DNS",
"mtu": "وحدة الإرسال القصوى",
"autoRedirect": "Auto Redirect"
},
"tooltips": {
"dnsHijack": "Please use , to separate multiple DNS servers",
"dnsMode": "يحافظ Auto على السلوك القديم؛ يتطلب hijack تفعيل توجيه صارم على Windows أو Auto Redirect على Linux",
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
+1
View File
@@ -36,6 +36,7 @@
"yes": "Yes",
"no": "No",
"failed": "Failed",
"risky": "غير مؤكد",
"completed": "Completed",
"disallowedIsp": "Disallowed ISP",
"originalsOnly": "Originals Only",
+2
View File
@@ -70,6 +70,8 @@
"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
@@ -495,11 +495,13 @@
"strictRoute": "Strenges Routing",
"autoDetectInterface": "Netzwerkschnittstelle automatisch auswählen",
"dnsHijack": "DNS-Hijacking",
"dnsMode": "DNS-Modus",
"mtu": "Maximale Übertragungseinheit",
"autoRedirect": "Automatische Umleitung"
},
"tooltips": {
"dnsHijack": "Mehrere DNS-Server durch Kommas trennen",
"dnsMode": "Auto behält das alte Verhalten bei; hijack erfordert Strenges Routing unter Windows oder Automatische Umleitung unter Linux",
"autoRedirect": "Richtet TCP-Umleitungen per nftables/iptables automatisch ein"
},
"messages": {
+1
View File
@@ -36,6 +36,7 @@
"yes": "Unterstützt",
"no": "Nicht unterstützt",
"failed": "Test fehlgeschlagen",
"risky": "Ungewiss",
"completed": "Prüfung abgeschlossen",
"disallowedIsp": "Nicht zugelassener Internetdienstanbieter",
"originalsOnly": "Nur Original",
+2
View File
@@ -70,6 +70,8 @@
"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
@@ -495,11 +495,13 @@
"strictRoute": "Strict Route",
"autoDetectInterface": "Auto Detect Interface",
"dnsHijack": "DNS Hijack",
"dnsMode": "DNS Mode",
"mtu": "Max Transmission Unit",
"autoRedirect": "Auto Redirect"
},
"tooltips": {
"dnsHijack": "Please use , to separate multiple DNS servers",
"dnsMode": "Auto keeps the legacy behavior; hijack requires Strict Route on Windows or Auto Redirect on Linux",
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
+1
View File
@@ -36,6 +36,7 @@
"yes": "Yes",
"no": "No",
"failed": "Failed",
"risky": "Risky",
"completed": "Completed",
"disallowedIsp": "Disallowed ISP",
"originalsOnly": "Originals Only",
+2
View File
@@ -70,6 +70,8 @@
"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
@@ -495,11 +495,13 @@
"strictRoute": "Enrutamiento estricto",
"autoDetectInterface": "Detectar automáticamente la interfaz de salida del tráfico",
"dnsHijack": "Secuestro de DNS",
"dnsMode": "Modo DNS",
"mtu": "Unidad máxima de transmisión",
"autoRedirect": "Auto Redirect"
},
"tooltips": {
"dnsHijack": "Please use , to separate multiple DNS servers",
"dnsMode": "Auto mantiene el comportamiento anterior; hijack requiere Enrutamiento estricto en Windows o Auto Redirect en Linux",
"autoRedirect": "Automatically configures nftables/iptables TCP redirects"
},
"messages": {
+1
View File
@@ -36,6 +36,7 @@
"yes": "Soportado",
"no": "No soportado",
"failed": "Prueba fallida",
"risky": "Dudoso",
"completed": "Detección completada",
"disallowedIsp": "Proveedor de servicios de Internet no permitido",
"originalsOnly": "Solo originales",
+2
View File
@@ -70,6 +70,8 @@
"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
@@ -495,11 +495,13 @@
"strictRoute": "مسیر دقیق",
"autoDetectInterface": "تشخیص خودکار رابط",
"dnsHijack": "ربایش DNS",
"dnsMode": "حالت DNS",
"mtu": "واحد حداکثر انتقال",
"autoRedirect": "Auto Redirect"
},
"tooltips": {
"dnsHijack": "لطفا برای جدا کردن چندین سرور DNS از , استفاده کنید.",
"dnsMode": "Auto رفتار قدیمی را حفظ می‌کند؛ hijack در ویندوز نیاز به مسیر دقیق یا در لینوکس نیاز به Auto Redirect دارد",
"autoRedirect": "پیکربندی خودکار ریدایرکت‌های TCP در nftables/iptables"
},
"messages": {
+1
View File
@@ -36,6 +36,7 @@
"yes": "Yes",
"no": "No",
"failed": "Failed",
"risky": "نامشخص",
"completed": "Completed",
"disallowedIsp": "Disallowed ISP",
"originalsOnly": "Originals Only",

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