Compare commits

..
Author SHA1 Message Date
renovate[bot] 72d8b144a6 chore(deps): update tauri 2026-10-17 13:53:43 +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
51 changed files with 1477 additions and 1490 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.0",
"pnpmVersion": "12.14.0"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0",
"version": "1.99.1",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+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: '24.22.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.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: '24.22.0'
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: '24.22.0'
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: '24.22.0'
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: '24.22.0'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.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: '24.22.0'
cache: 'pnpm'
- name: Pnpm Cache
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.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."
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.21.0'
node-version: '24.22.0'
cache: 'pnpm'
- name: Restore pnpm cache
@@ -138,7 +138,7 @@ jobs:
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
node-version: '24.22.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.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: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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: '24.22.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -573,7 +573,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -638,7 +638,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.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: '24.22.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '24.22.0'
- 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.0
pnpm 12.14.0
rust 1.99.1
Generated
+431 -311
View File
File diff suppressed because it is too large Load Diff
+3 -12
View File
@@ -49,15 +49,7 @@ split-debuginfo = "unpacked"
[patch.crates-io]
# Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
# tauri-specta has no tauri 3 release yet; ride the tauri team's port and the
# unpublished specta rc.26 line it pins. Drop all six entries once both ship
# on crates.io.
tauri-specta = { git = "https://github.com/tauri-apps/tauri-specta", branch = "tauri-v3" }
specta = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-macros = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-serde = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-typescript = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
specta-util = { git = "https://github.com/specta-rs/specta.git", rev = "2757fa8899926dce7da999780c7edf2dc08a732a" }
[workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -69,9 +61,8 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "3.0.0-alpha.4" }
tauri-runtime-wry = "3.0.0-alpha.4"
tauri-plugin-clipboard-manager = "3.0.0-alpha.2"
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"
+7 -1
View File
@@ -3,7 +3,13 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
clash-verge-logging = { workspace = true }
chrono = { workspace = true }
log = { workspace = true }
+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"
);
}
}
@@ -5,7 +5,7 @@ edition = "2024"
rust-version = "1.91"
[dependencies]
specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true }
+15 -15
View File
@@ -54,13 +54,13 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "3.0.0-alpha.2",
"@tauri-apps/plugin-clipboard-manager": "^3.0.0-alpha.2",
"@tauri-apps/plugin-dialog": "^3.0.0-alpha.3",
"@tauri-apps/plugin-fs": "^3.0.0-alpha.2",
"@tauri-apps/plugin-http": "~3.0.0-alpha.2",
"@tauri-apps/plugin-opener": "3.0.0-alpha.2",
"@tauri-apps/plugin-updater": "3.0.0-alpha.2",
"@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.0.19",
"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": "3.0.0-alpha.4",
"@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,7 +123,7 @@
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"knip": "6.41.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
@@ -133,7 +133,7 @@
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"webdriverio": "10.0.2",
"ws": "8.22.0"
},
"lint-staged": {
@@ -146,5 +146,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.11.2"
"packageManager": "pnpm@12.14.0"
}
+451 -452
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -10,15 +10,3 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- '@tauri-apps/cli-darwin-arm64@3.0.0-alpha.4'
- '@tauri-apps/cli-darwin-x64@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm-gnueabihf@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-arm64-musl@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-riscv64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-x64-gnu@3.0.0-alpha.4'
- '@tauri-apps/cli-linux-x64-musl@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-arm64-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-ia32-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli-win32-x64-msvc@3.0.0-alpha.4'
- '@tauri-apps/cli@3.0.0-alpha.4'
+11
View File
@@ -45,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"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.0"
channel = "1.99.1"
components = ["rustfmt", "clippy"]
+1 -1
View File
@@ -22,7 +22,7 @@ try {
'run',
'-p',
'clash-verge',
'--bin',
'--example',
'frontend-contract',
'--locked',
'-q',
+24 -22
View File
@@ -23,33 +23,30 @@ tauri-dev = ["dep:tauri-plugin-devtools"]
tokio-trace = ["clash-verge-logging/tokio-trace"]
chrome-trace = ["clash-verge-logging/chrome-trace"]
clippy = ["tauri/test"]
# The perf-harness feature and its main.rs wiring are parked until
# tauri-plugin-wdio-webdriver publishes a tauri 3 release; restore both
# from git history together with the crate dependency.
perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
[package.metadata.bundle]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "3.0.0-alpha.3", 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 }
tauri-plugin-clipboard-manager = { workspace = true }
tauri = { workspace = true, features = [
"protocol-asset",
"devtools",
"tracing",
"tray-icon",
"image-ico",
"image-png",
] }
tauri-runtime-wry = { workspace = true, features = ["devtools"] }
parking_lot = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
@@ -75,13 +72,13 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "3.0.0-alpha.2"
tauri-plugin-opener = "3.0.0-alpha.2"
tauri-plugin-dialog = "3.0.0-alpha.3"
tauri-plugin-fs = "3.0.0-alpha.2"
tauri-plugin-process = "3.0.0-alpha.2"
tauri-plugin-deep-link = "3.0.0-alpha.2"
tauri-plugin-window-state = "3.0.0-alpha.2"
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.2"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.1"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
@@ -93,13 +90,13 @@ getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "3.0.0-alpha.2"
tauri-plugin-notification = "2.7.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "3.0.0-alpha.2"
tauri-plugin-http = "2.8.3"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "3.0.0-alpha.0", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "feat/tauri-v3" }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
@@ -111,14 +108,19 @@ webpki-roots = "1.0.9"
dark-light = "3.0.0"
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
specta = { version = "=2.0.0-rc.26", features = ["derive", "function", "serde_json"] }
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.13"
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
@@ -150,9 +152,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "3.0.0-alpha.2"
tauri-plugin-global-shortcut = "3.0.0-alpha.2"
tauri-plugin-updater = "3.0.0-alpha.2"
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.0"
[dev-dependencies]
criterion = { workspace = true }
+1
View File
@@ -70,6 +70,7 @@
"dialog:allow-open",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"process:allow-exit",
@@ -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 -4
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 {
@@ -409,6 +408,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 {
+1 -1
View File
@@ -24,7 +24,7 @@ pub async fn update_launch() -> Result<()> {
#[cfg(not(target_os = "windows"))]
{
let app_handle = Handle::app_handle();
let autostart_manager = app_handle.autostart();
let autostart_manager = app_handle.autolaunch();
if is_enable {
logging_error!(Type::System, "{:?}", autostart_manager.enable());
} else {
+30 -34
View File
@@ -23,9 +23,8 @@ use smartstring::alias::String;
use std::borrow::Cow;
use std::collections::HashMap;
use std::time::Duration;
use tauri::Manager as _;
use tauri::{
AppHandle, DynRuntime,
AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
@@ -37,7 +36,7 @@ use menu_def::{MenuIds, MenuTexts};
// TODO: 是否需要将可变菜单抽离存储起来,后续直接更新对应菜单实例,无需重新创建菜单(待考虑)
type ProxyMenuItem = (Option<Submenu<DynRuntime>>, Vec<Box<dyn IsMenuItem<DynRuntime>>>);
type ProxyMenuItem = (Option<Submenu<Wry>>, Vec<Box<dyn IsMenuItem<Wry>>>);
const TRAY_CLICK_DEBOUNCE_MS: u64 = 300;
pub const TRAY_ID: &str = "clash-verge-rev-tray";
@@ -58,14 +57,14 @@ enum IconKind {
}
struct LatencyMenuItem {
item: MenuItem<DynRuntime>,
item: MenuItem<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<DynRuntime>,
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
@@ -567,7 +566,7 @@ fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
fn create_profile_menu_item(
app_handle: &AppHandle,
profiles_preview: Vec<IProfilePreview<'_>>,
) -> Result<Vec<CheckMenuItem<DynRuntime>>> {
) -> Result<Vec<CheckMenuItem<Wry>>> {
profiles_preview
.into_iter()
.map(|profile| {
@@ -678,7 +677,7 @@ fn create_subcreate_proxy_menu_item(
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
) -> Vec<Submenu<DynRuntime>> {
) -> Vec<Submenu<Wry>> {
let Some(view) = view else { return Vec::new() };
view.groups
.iter()
@@ -740,8 +739,8 @@ fn create_subcreate_proxy_menu_item(
},
);
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
let mut items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<DynRuntime>));
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),
@@ -758,7 +757,7 @@ fn create_subcreate_proxy_menu_item(
fn create_proxy_menu_item(
app_handle: &AppHandle,
show_proxy_groups_inline: bool,
proxy_submenus: Vec<Submenu<DynRuntime>>,
proxy_submenus: Vec<Submenu<Wry>>,
proxies_text: &str,
) -> Result<ProxyMenuItem> {
let (proxies_submenu, inline_proxy_items) = if show_proxy_groups_inline {
@@ -766,13 +765,13 @@ fn create_proxy_menu_item(
None,
proxy_submenus
.into_iter()
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<DynRuntime>>)
.map(|submenu| Box::new(submenu) as Box<dyn IsMenuItem<Wry>>)
.collect(),
)
} else if !proxy_submenus.is_empty() {
let proxy_submenu_refs: Vec<&dyn IsMenuItem<DynRuntime>> = proxy_submenus
let proxy_submenu_refs: Vec<&dyn IsMenuItem<Wry>> = proxy_submenus
.iter()
.map(|submenu| submenu as &dyn IsMenuItem<DynRuntime>)
.map(|submenu| submenu as &dyn IsMenuItem<Wry>)
.collect();
(
@@ -799,10 +798,7 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<(
tauri::menu::Menu<DynRuntime>,
HashMap<std::string::String, LatencyMenuItem>,
)> {
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
@@ -840,12 +836,12 @@ async fn create_tray_menu(
let hotkeys = create_hotkeys(&verge_settings.hotkeys);
let profile_menu_items: Vec<CheckMenuItem<DynRuntime>> = create_profile_menu_item(app_handle, profiles_preview)?;
let profile_menu_items: Vec<CheckMenuItem<Wry>> = create_profile_menu_item(app_handle, profiles_preview)?;
let texts = MenuTexts::new();
let profile_menu_items_refs: Vec<&dyn IsMenuItem<DynRuntime>> = profile_menu_items
let profile_menu_items_refs: Vec<&dyn IsMenuItem<Wry>> = profile_menu_items
.iter()
.map(|item| item as &dyn IsMenuItem<DynRuntime>)
.map(|item| item as &dyn IsMenuItem<Wry>)
.collect();
let open_window = &MenuItem::with_id(
@@ -898,9 +894,9 @@ async fn create_tray_menu(
outbound_modes_label.as_str(),
true,
&[
rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<DynRuntime>,
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
],
)?)
};
@@ -1013,7 +1009,7 @@ async fn create_tray_menu(
&texts.more,
true,
&[
copy_env as &dyn IsMenuItem<DynRuntime>,
copy_env as &dyn IsMenuItem<Wry>,
close_all_connections,
restart_clash,
restart_app,
@@ -1030,13 +1026,13 @@ async fn create_tray_menu(
let separator = &PredefinedMenuItem::separator(app_handle)?;
let mut menu_items: Vec<&dyn IsMenuItem<DynRuntime>> = vec![open_window, separator];
let mut menu_items: Vec<&dyn IsMenuItem<Wry>> = vec![open_window, separator];
if show_outbound_modes_inline {
menu_items.extend_from_slice(&[
rule_mode as &dyn IsMenuItem<DynRuntime>,
global_mode as &dyn IsMenuItem<DynRuntime>,
direct_mode as &dyn IsMenuItem<DynRuntime>,
rule_mode as &dyn IsMenuItem<Wry>,
global_mode as &dyn IsMenuItem<Wry>,
direct_mode as &dyn IsMenuItem<Wry>,
]);
} else if let Some(ref outbound_modes) = outbound_modes {
menu_items.push(outbound_modes);
@@ -1056,14 +1052,14 @@ async fn create_tray_menu(
menu_items.extend_from_slice(&[
separator,
system_proxy as &dyn IsMenuItem<DynRuntime>,
tun_mode as &dyn IsMenuItem<DynRuntime>,
system_proxy as &dyn IsMenuItem<Wry>,
tun_mode as &dyn IsMenuItem<Wry>,
separator,
lightweight_mode as &dyn IsMenuItem<DynRuntime>,
open_dir as &dyn IsMenuItem<DynRuntime>,
more as &dyn IsMenuItem<DynRuntime>,
lightweight_mode as &dyn IsMenuItem<Wry>,
open_dir as &dyn IsMenuItem<Wry>,
more as &dyn IsMenuItem<Wry>,
separator,
quit as &dyn IsMenuItem<DynRuntime>,
quit as &dyn IsMenuItem<Wry>,
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
+2 -2
View File
@@ -132,7 +132,7 @@ impl TraySpeedController {
let app_handle = handle::Handle::app_handle();
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
let result = tray.with_inner_blocking(|inner| {
let result = tray.with_inner_tray_icon(|inner| {
if let Some(status_item) = inner.ns_status_item() {
tray_speed::clear_speed_attributed_title(&status_item);
}
@@ -167,7 +167,7 @@ impl TraySpeedController {
fn apply_tray_speed(up: u64, down: u64) {
let app_handle = handle::Handle::app_handle();
if let Some(tray) = app_handle.tray_by_id(super::TRAY_ID) {
let result = tray.with_inner_blocking(move |inner| {
let result = tray.with_inner_tray_icon(move |inner| {
if let Some(status_item) = inner.ns_status_item() {
tray_speed::set_speed_attributed_title(&status_item, up, down);
}
+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 {
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::cmd;
pub fn builder() -> tauri_specta::Builder<tauri::DynRuntime> {
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::new()
.types(&platform_types())
.commands(tauri_specta::collect_commands![
tauri_plugin_clash_verge_sysinfo::commands::get_system_info,
tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::DynRuntime>,
tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::<tauri::Wry>,
cmd::probe_listener,
cmd::save_proxy_ports,
cmd::get_sys_proxy,
+4 -3
View File
@@ -40,7 +40,7 @@ mod app_init {
}
/// Setup plugins for the Tauri builder
pub fn setup_plugins(builder: tauri::Builder) -> tauri::Builder {
pub fn setup_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
#[allow(unused_mut)]
let mut builder = builder
.plugin(tauri_plugin_clash_verge_sysinfo::init())
@@ -124,7 +124,7 @@ mod app_init {
Ok(())
}
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static {
pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync + 'static {
{
let builder = crate::ipc::builder();
move |invoke| builder.invoke_handler()(invoke)
@@ -181,7 +181,7 @@ pub fn run() -> std::process::ExitCode {
#[cfg(target_os = "linux")]
utils::linux::workarounds::apply_wayland_webkit_fix();
let builder = app_init::setup_plugins(tauri::Builder::default().runtime(tauri_runtime_wry::Wry::default()))
let builder = app_init::setup_plugins(tauri::Builder::default())
.setup(|app| {
// Logger may not be ready yet, so mirror setup panics to stderr.
fn log_setup_panic(stage: &str, panic: Box<dyn std::any::Any + Send>) {
@@ -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);
+12 -1
View File
@@ -1,11 +1,22 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::ExitCode;
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
#[path = "../../scripts/perf/app.rs"]
mod perf;
#[cfg(all(feature = "perf-harness", target_os = "macos"))]
fn main() -> ExitCode {
perf::run()
}
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode {
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 -4
View File
@@ -132,10 +132,7 @@ pub fn take_webview_needs_reload() -> bool {
/// reload visible webviews immediately, and defer a hidden main-window reload until activation.
/// Registering this callback replaces Tauri's default automatic reload.
#[cfg(target_os = "macos")]
pub fn on_web_content_process_terminated(
webview: &tauri::Webview,
_termination: &tauri::webview::WebContentProcessTermination,
) {
pub fn on_web_content_process_terminated(webview: &tauri::Webview) {
if handle::Handle::global().is_exiting() {
return;
}
+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);
}
+9 -9
View File
@@ -4,7 +4,7 @@ use clash_verge_logging::{Type, logging};
use std::pin::Pin;
use std::sync::LazyLock;
use std::time::Duration;
use tauri::{Manager as _, WebviewWindow};
use tauri::{Manager as _, WebviewWindow, Wry};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindowOperationResult {
@@ -50,7 +50,7 @@ impl WindowManager {
handle::Handle::global().set_activation_policy_regular();
}
fn get_main_window_with_state() -> (Option<WebviewWindow>, WindowState) {
fn get_main_window_with_state() -> (Option<WebviewWindow<Wry>>, WindowState) {
let Some(window) = Self::get_main_window() else {
return (None, WindowState::NotExist);
};
@@ -76,7 +76,7 @@ impl WindowManager {
Self::get_main_window_with_state().1
}
pub fn get_main_window() -> Option<WebviewWindow> {
pub fn get_main_window() -> Option<WebviewWindow<Wry>> {
let app_handle = handle::Handle::app_handle();
app_handle.get_webview_window("main")
}
@@ -147,7 +147,7 @@ impl WindowManager {
}
}
fn hide_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
fn hide_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口可见,将隐藏窗口");
if let Some(window) = window {
match window.close() {
@@ -166,7 +166,7 @@ impl WindowManager {
}
}
fn activate_existing_main_window(window: Option<&WebviewWindow>) -> WindowOperationResult {
fn activate_existing_main_window(window: Option<&WebviewWindow<Wry>>) -> WindowOperationResult {
logging!(debug, Type::Window, "窗口存在但被隐藏或最小化,将激活窗口");
if let Some(window) = window {
Self::activate_window(window)
@@ -176,7 +176,7 @@ impl WindowManager {
}
}
fn activate_window(window: &WebviewWindow) -> WindowOperationResult {
fn activate_window(window: &WebviewWindow<Wry>) -> WindowOperationResult {
logging!(debug, Type::Window, "开始激活窗口");
#[cfg(target_os = "macos")]
Self::set_macos_activation_policy_regular();
@@ -233,15 +233,15 @@ impl WindowManager {
}
}
pub fn is_main_window_visible(window: Option<&WebviewWindow>) -> bool {
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
}
pub fn is_main_window_focused(window: Option<&WebviewWindow>) -> bool {
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
}
fn is_main_window_minimized(window: Option<&WebviewWindow>) -> bool {
fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
}
+1 -1
View File
@@ -101,7 +101,7 @@ export const SystemInfoCard = () => {
try {
const result = await triggerCheckUpdate()
const info = result.data
if (!info) {
if (!info?.available) {
showNotice.success(
'settings.components.verge.advanced.notifications.latestVersion',
)
+1 -1
View File
@@ -16,7 +16,7 @@ export const UpdateButton = (props: Props) => {
const { updateInfo } = useUpdate()
if (!updateInfo) return null
if (!updateInfo?.available) return null
return (
<>
+14 -5
View File
@@ -44,6 +44,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,
@@ -90,11 +91,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 +104,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(
@@ -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>
@@ -48,7 +48,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
try {
const info = await checkUpdate()
updateLastCheckTime()
if (!info) {
if (!info?.available) {
showNotice.success(
'settings.components.verge.advanced.notifications.latestVersion',
)
+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 = () => {
+1 -8
View File
@@ -7,10 +7,8 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import type { IVergeTestItem_Serialize } from '@/services/bindings'
import { showNotice } from '@/services/notice-service'
interface Props {
onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void
}
@@ -79,12 +77,7 @@ export const TestViewer = forwardRef<TestViewerRef, Props>(
if (openType === 'new') {
const uid = nanoid()
const item: IVergeTestItem_Serialize = {
uid,
name: form.name ?? null,
icon: form.icon ?? null,
url: form.url ?? null,
}
const item = { ...form, uid }
const newList = [...testList, item]
await patchVerge({ test_list: newList })
onChange(uid)
+2 -5
View File
@@ -51,6 +51,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 +61,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
+117 -106
View File
@@ -53,11 +53,16 @@ export const commands = {
/** Reads the saved mode without depending on strict mihomo `/configs` deserialization. */
getClashMode: () => __TAURI_INVOKE<string | null>('get_clash_mode'),
changeClashCore: (clashCore: string) =>
__TAURI_INVOKE<CommandFailure_Serialize | null>('change_clash_core', {
clashCore,
}),
__TAURI_INVOKE<{
/** Stable frontend identifier, when classified. */
code?: string | null
/** Human-readable diagnostic detail. */
detail: string
/** Requested operation when known by the command. */
operation?: FailedOperation | null
} | null>('change_clash_core', { clashCore }),
getRuntimeConfig: () =>
__TAURI_INVOKE<YamlMapping | null>('get_runtime_config'),
__TAURI_INVOKE<{ [key in string]: JsonValue } | null>('get_runtime_config'),
getProxyView: () => __TAURI_INVOKE<ProxyViewV1_Serialize>('get_proxy_view'),
getRuntimeYaml: () => __TAURI_INVOKE<string>('get_runtime_yaml'),
getRuntimeLogs: () => __TAURI_INVOKE<RuntimeLogs>('get_runtime_logs'),
@@ -65,7 +70,7 @@ export const commands = {
__TAURI_INVOKE<string>('get_runtime_proxy_chain_config', {
proxyChainExitNode,
}),
updateProxyChainConfigInRuntime: (proxyChainConfig: YamlValue | null) =>
updateProxyChainConfigInRuntime: (proxyChainConfig: JsonValue | null) =>
__TAURI_INVOKE<ValidationOutcome>('update_proxy_chain_config_in_runtime', {
proxyChainConfig,
}),
@@ -95,11 +100,18 @@ export const commands = {
takeDnsOverrideNotice: () =>
__TAURI_INVOKE<boolean>('take_dns_override_notice'),
takeServiceFallbackNotice: () =>
__TAURI_INVOKE<ServiceFallbackNotice | null>(
'take_service_fallback_notice',
),
__TAURI_INVOKE<
| { kind: 'unavailable' }
| { kind: 'coreRejected'; reason: string }
| { kind: 'notAutoStarted' }
| null
>('take_service_fallback_notice'),
getCoreStartupError: () =>
__TAURI_INVOKE<CoreFailure | null>('get_core_startup_error'),
__TAURI_INVOKE<
| { kind: 'startFailed'; detail: string }
| { kind: 'serviceCoreStopped'; detail: string }
| null
>('get_core_startup_error'),
takeServiceRepairNotice: () =>
__TAURI_INVOKE<boolean>('take_service_repair_notice'),
takeServiceOwnerNotice: () =>
@@ -128,7 +140,7 @@ export const commands = {
__TAURI_INVOKE<NetworkInterfaceView[]>('get_network_interfaces_info'),
getProfiles: () => __TAURI_INVOKE<IProfiles_Serialize>('get_profiles'),
enhanceProfiles: () => __TAURI_INVOKE<ValidationOutcome>('enhance_profiles'),
patchProfilesConfig: (profiles: ProfileSelection_Deserialize) =>
patchProfilesConfig: (profiles: ProfileSelection) =>
__TAURI_INVOKE<ValidationOutcome>('patch_profiles_config', { profiles }),
viewProfile: (index: string) =>
__TAURI_INVOKE<null>('view_profile', { index }),
@@ -136,12 +148,52 @@ export const commands = {
__TAURI_INVOKE<null>('patch_profile', { index, profile }),
createProfile: (item: ProfileCreate_Deserialize, fileData: string | null) =>
__TAURI_INVOKE<null>('create_profile', { item, fileData }),
importProfile: (url: string, option: PrfOption_Deserialize | null) =>
__TAURI_INVOKE<null>('import_profile', { url, option }),
importProfile: (
url: string,
option: {
user_agent?: string | null
with_proxy?: boolean | null
self_proxy?: boolean | null
update_interval?: number | null
/**
* HTTP request timeout in seconds
* default is 60 seconds
*/
timeout_seconds?: number | null
/** default is `false` */
danger_accept_invalid_certs?: boolean | null
allow_auto_update?: boolean | null
merge?: string | null
script?: string | null
rules?: string | null
proxies?: string | null
groups?: string | null
} | null,
) => __TAURI_INVOKE<null>('import_profile', { url, option }),
reorderProfile: (activeId: string, overId: string) =>
__TAURI_INVOKE<null>('reorder_profile', { activeId, overId }),
updateProfile: (index: string, option: PrfOption_Deserialize | null) =>
__TAURI_INVOKE<null>('update_profile', { index, option }),
updateProfile: (
index: string,
option: {
user_agent?: string | null
with_proxy?: boolean | null
self_proxy?: boolean | null
update_interval?: number | null
/**
* HTTP request timeout in seconds
* default is 60 seconds
*/
timeout_seconds?: number | null
/** default is `false` */
danger_accept_invalid_certs?: boolean | null
allow_auto_update?: boolean | null
merge?: string | null
script?: string | null
rules?: string | null
proxies?: string | null
groups?: string | null
} | null,
) => __TAURI_INVOKE<null>('update_profile', { index, option }),
deleteProfile: (index: string) =>
__TAURI_INVOKE<null>('delete_profile', { index }),
readProfileFile: (index: string) =>
@@ -211,11 +263,11 @@ export type CommandFailure_Deserialize = {
/** Structured command failure returned to the frontend. */
export type CommandFailure_Serialize = {
/** Stable frontend identifier, when classified. */
code?: string
code?: string | null
/** Human-readable diagnostic detail. */
detail: string
/** Requested operation when known by the command. */
operation?: FailedOperation
operation?: FailedOperation | null
}
/** Why the Core is not running, kept until a Core starts again. */
@@ -278,27 +330,14 @@ export type IProfiles_Serialize = {
/** ### `verge.yaml` schema */
export type IVerge = IVerge_Serialize | IVerge_Deserialize
export type IVergeTestItem =
| IVergeTestItem_Serialize
| IVergeTestItem_Deserialize
export type IVergeTestItem_Deserialize = {
export type IVergeTestItem = {
uid?: string | null
name?: string | null
icon?: string | null
url?: string | null
}
export type IVergeTestItem_Serialize = {
uid: string | null
name: string | null
icon: string | null
url: string | null
}
export type IVergeTheme = IVergeTheme_Serialize | IVergeTheme_Deserialize
export type IVergeTheme_Deserialize = {
export type IVergeTheme = {
primary_color?: string | null
secondary_color?: string | null
primary_text?: string | null
@@ -311,19 +350,6 @@ export type IVergeTheme_Deserialize = {
css_injection?: string | null
}
export type IVergeTheme_Serialize = {
primary_color: string | null
secondary_color: string | null
primary_text: string | null
secondary_text: string | null
info_color: string | null
error_color: string | null
warning_color: string | null
success_color: string | null
font_family: string | null
css_injection: string | null
}
/** ### `verge.yaml` schema */
export type IVerge_Deserialize = {
/** silent | error | warn | info | debug | trace */
@@ -366,7 +392,7 @@ export type IVerge_Deserialize = {
proxy_auto_config?: boolean | null
pac_file_content?: string | null
proxy_host?: string | null
theme_setting?: IVergeTheme_Deserialize | null
theme_setting?: IVergeTheme | null
web_ui_list?: string[] | null
clash_core?: string | null
/** format: {func},{key} */
@@ -382,7 +408,7 @@ export type IVerge_Deserialize = {
auto_delay_detection_interval_minutes?: number | null
enable_builtin_enhanced?: boolean | null
proxy_layout_column?: number | null
test_list?: IVergeTestItem_Deserialize[] | null
test_list?: IVergeTestItem[] | null
/** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */
auto_log_clean?: number | null
enable_auto_backup_schedule?: boolean | null
@@ -431,13 +457,13 @@ export type IVerge_Serialize = {
traffic_graph: boolean | null
/** show memory info (only for Clash Meta) */
enable_memory_usage: boolean | null
enable_group_icon?: boolean
enable_group_icon?: boolean | null
pause_render_traffic_stats_on_blur: boolean | null
common_tray_icon?: boolean
menu_icon?: string
menu_order?: string[]
proxy_group_tools_position?: string
notice_position?: string
common_tray_icon?: boolean | null
menu_icon?: string | null
menu_order?: string[] | null
proxy_group_tools_position?: string | null
notice_position?: string | null
collapse_navbar: boolean | null
sysproxy_tray_icon: boolean | null
tun_tray_icon: boolean | null
@@ -456,11 +482,11 @@ export type IVerge_Serialize = {
proxy_auto_config: boolean | null
pac_file_content: string | null
proxy_host: string | null
theme_setting: IVergeTheme_Serialize | null
theme_setting: IVergeTheme | null
web_ui_list: string[] | null
clash_core?: string
clash_core?: string | null
/** format: {func},{key} */
hotkeys?: string[]
hotkeys?: string[] | null
enable_global_hotkey: boolean | null
home_cards: JsonValue | null
auto_close_connection: boolean | null
@@ -472,7 +498,7 @@ export type IVerge_Serialize = {
auto_delay_detection_interval_minutes: number | null
enable_builtin_enhanced: boolean | null
proxy_layout_column: number | null
test_list: IVergeTestItem_Serialize[] | null
test_list: IVergeTestItem[] | null
/** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */
auto_log_clean: number | null
enable_auto_backup_schedule: boolean | null
@@ -484,10 +510,10 @@ export type IVerge_Serialize = {
verge_socks_enabled: boolean | null
verge_port: number | null
verge_http_enabled: boolean | null
webdav_url?: string
webdav_username?: string
webdav_password?: string
tray_proxy_groups_display_mode?: string
webdav_url?: string | null
webdav_username?: string | null
webdav_password?: string | null
tray_proxy_groups_display_mode?: string | null
tray_inline_outbound_modes: boolean | null
enable_auto_light_weight_mode: boolean | null
/** 自动进入轻量模式的延迟(分钟) */
@@ -632,7 +658,7 @@ export type PrfItem_Deserialize = {
file?: string | null
desc?: string | null
url?: string | null
selected?: PrfSelected_Deserialize[] | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
@@ -646,14 +672,14 @@ export type PrfItem_Serialize = {
type: string | null
name: string | null
file: string | null
desc?: string
url?: string
selected?: PrfSelected_Serialize[]
latency_test_urls?: { [key in string]: string }
extra?: PrfExtra
desc?: string | null
url?: string | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated: number | null
option?: PrfOption_Serialize
home?: string
option?: PrfOption_Serialize | null
home?: string | null
}
export type PrfOption = PrfOption_Serialize | PrfOption_Deserialize
@@ -679,18 +705,18 @@ export type PrfOption_Deserialize = {
}
export type PrfOption_Serialize = {
user_agent?: string
with_proxy?: boolean
self_proxy?: boolean
update_interval?: number
user_agent?: string | null
with_proxy?: boolean | null
self_proxy?: boolean | null
update_interval?: number | null
/**
* HTTP request timeout in seconds
* default is 60 seconds
*/
timeout_seconds?: number
timeout_seconds?: number | null
/** default is `false` */
danger_accept_invalid_certs?: boolean
allow_auto_update?: boolean
danger_accept_invalid_certs?: boolean | null
allow_auto_update?: boolean | null
merge: string | null
script: string | null
rules: string | null
@@ -698,18 +724,11 @@ export type PrfOption_Serialize = {
groups: string | null
}
export type PrfSelected = PrfSelected_Serialize | PrfSelected_Deserialize
export type PrfSelected_Deserialize = {
export type PrfSelected = {
name?: string | null
now?: string | null
}
export type PrfSelected_Serialize = {
name: string | null
now: string | null
}
export type ProfileCreate = ProfileCreate_Serialize | ProfileCreate_Deserialize
export type ProfileCreate_Deserialize = {
@@ -739,7 +758,7 @@ export type ProfileDnsSettings_Deserialize = {
export type ProfileDnsSettings_Serialize = {
enabled: boolean
confirmation?: string
confirmation?: string | null
}
export type ProfilePatch = ProfilePatch_Serialize | ProfilePatch_Deserialize
@@ -750,7 +769,7 @@ export type ProfilePatch_Deserialize = {
desc?: string | null
file?: string | null
url?: string | null
selected?: PrfSelected_Deserialize[] | null
selected?: PrfSelected[] | null
latency_test_urls?: { [key in string]: string } | null
extra?: PrfExtra | null
updated?: number | null
@@ -763,25 +782,17 @@ export type ProfilePatch_Serialize = {
desc: string | null
file: string | null
url: string | null
selected: PrfSelected_Serialize[] | null
selected: PrfSelected[] | null
latency_test_urls: { [key in string]: string } | null
extra: PrfExtra | null
updated: number | null
option: PrfOption_Serialize | null
}
export type ProfileSelection =
| ProfileSelection_Serialize
| ProfileSelection_Deserialize
export type ProfileSelection_Deserialize = {
export type ProfileSelection = {
current?: string | null
}
export type ProfileSelection_Serialize = {
current: string | null
}
export type ProfileUpdatePayload = {
uid: string
}
@@ -814,11 +825,11 @@ export type ProxyGroupView_Deserialize = {
export type ProxyGroupView_Serialize = {
name: string
type: string
now?: string
fixed?: string
hidden?: boolean
icon?: string
testUrl?: string
now?: string | null
fixed?: string | null
hidden?: boolean | null
icon?: string | null
testUrl?: string | null
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
members: ProxyMemberRef[]
@@ -863,10 +874,10 @@ export type ProxyNodeView_Serialize = {
type: string
history: DelayHistory[]
extra: { [key in string]: ProxyLatencyHistory }
id?: string
hidden?: boolean
icon?: string
testUrl?: string
id?: string | null
hidden?: boolean | null
icon?: string | null
testUrl?: string | null
source: ProxyNodeSource
} & ProxyCapabilities
@@ -895,8 +906,8 @@ export type ProxyProviderView_Deserialize = {
export type ProxyProviderView_Serialize = {
name: string
vehicleType: ProxyProviderVehicleType
updatedAt?: string
subscriptionInfo?: ProxySubscriptionInfo
updatedAt?: string | null
subscriptionInfo?: ProxySubscriptionInfo | null
proxyRecordIds: string[]
}
+207 -216
View File
@@ -32,230 +32,221 @@ export const forgetShownStartupError = () => {
* type makes a backend status without a frontend entry a compile error (and
* contract regeneration keeps the union in sync).
*/
const handlers: Record<
NoticeStatus,
(msg: string, navigate: NavigateFunction) => void
> = {
info: (msg) => {
if (msg) showNotice.info(msg)
},
'import_sub_url::ok': (_msg, navigate) => {
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
// navigate("/profile", { state: { current: msg } });
navigate('/profile')
showNotice.success(
'shared.feedback.notifications.importSubscriptionSuccess',
)
},
'import_sub_url::error': (msg, navigate) => {
navigate('/profile')
showNotice.error(msg)
},
'set_config::error': (msg) => showNotice.error(msg),
'core_start::error': () => {
const read = ++startupErrorReads
void getCoreStartupError()
.then(async (failure) => {
if (read < settledStartupErrorRead) return
settledStartupErrorRead = read
if (!failure) {
shownStartupError = null
return
}
const window = getCurrentWindow()
const [visible, minimized] = await Promise.all([
window.isVisible(),
window.isMinimized(),
])
const shown = `${failure.kind}:${failure.detail}`
if (
read === settledStartupErrorRead &&
visible &&
!minimized &&
shownStartupError !== shown
) {
shownStartupError = shown
showNotice.error(
failure.kind === 'serviceCoreStopped'
? 'settings.feedback.errors.clash.serviceCoreStopped'
: 'settings.feedback.errors.clash.startFailed',
failure.detail,
)
}
})
.catch((error) => {
console.error('Failed to read the pending core startup error', error)
})
},
'service_core::repair_required': () => {
void takeServiceRepairNotice()
.then((pending) => {
if (pending) {
requestService({ reason: 'serviceLocationRefused' })
}
})
.catch((error) => {
console.error('Failed to read the pending service repair notice', error)
})
},
'service_core::app_data_not_owned': () => {
void takeServiceOwnerNotice()
.then((command) => {
if (command) {
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
shownOwnerNotice = showNotice.warning(
'settings.feedback.notifications.clashService.appDataNotOwned',
{ command },
0,
)
}
})
.catch((error) => {
console.error('Failed to read the pending service owner notice', error)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((notice) => {
if (notice?.kind === 'coreRejected') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: notice.reason },
0,
)
} else if (notice?.kind === 'notAutoStarted') {
requestService({ reason: 'serviceNotAutoStarted' })
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
if (pending) {
showNotice.info('settings.modals.dns.protection.autoDisabled')
}
})
.catch((error) => {
console.error('Failed to read the pending DNS override notice', error)
})
},
'enhance::discarded_keys': () => {
void takeDiscardedKeysNotice()
.then((keys) => {
if (keys) {
showNotice.warning('profiles.page.feedback.notices.discardedKeys', {
keys,
})
}
})
.catch((error) => {
console.error('Failed to read the pending discarded keys notice', error)
})
},
'tun_mode::auto_disabled': () =>
showNotice.info(
'settings.sections.system.notifications.tunMode.autoDisabled',
),
'tun_mode::auto_disable_failed': () =>
showNotice.error(
'settings.sections.system.notifications.tunMode.autoDisableFailed',
),
'app_restart::core_stop_failed': (msg) =>
showNotice.error(
'layout.feedback.errors.restartCoreStopFailed',
msg || undefined,
),
'app_quit::core_stop_failed': (msg) =>
showNotice.error(
'layout.feedback.errors.quitCoreStopFailed',
msg || undefined,
),
update_with_clash_proxy: (msg) =>
showNotice.success(
'settings.feedback.notifications.updater.withClashProxySuccess',
msg,
),
update_failed_even_with_clash: (msg) =>
showNotice.error(
'settings.feedback.notifications.updater.withClashProxyFailed',
msg,
),
'reactivate_profiles::error': (msg) => showNotice.error(msg),
update_failed: (msg) => showNotice.error(msg),
'config_validate::boot_error': (msg) =>
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
'config_validate::error': (msg) =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::process_terminated': () =>
showNotice.error('shared.feedback.validation.config.processTerminated'),
'config_validate::script_error': (msg) =>
showNotice.error('shared.feedback.validation.script.fileError', msg),
'config_validate::script_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.script.syntaxError', msg),
'config_validate::script_missing_main': (msg) =>
showNotice.error('shared.feedback.validation.script.missingMain', msg),
'config_validate::file_not_found': (msg) =>
showNotice.error('shared.feedback.validation.script.fileNotFound', msg),
'config_validate::yaml_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.syntaxError', msg),
'config_validate::yaml_read_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.readError', msg),
'config_validate::yaml_mapping_error': (msg) =>
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
'config_validate::merge_syntax_error': (msg) =>
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
'config_validate::merge_mapping_error': (msg) =>
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
'config_core::change_success': (msg) =>
showNotice.success(
'settings.feedback.notifications.clash.changeSuccess',
msg,
),
'config_core::change_error': (msg) =>
showNotice.error('settings.feedback.notifications.clash.changeFailed', msg),
'mixed_port::fallback': (msg) => {
const [oldPort, newPort] = msg.split(',')
showNotice.info('settings.modals.clashPort.messages.automaticFallback', {
oldPort,
newPort,
})
},
'mixed_port::fallback_error': (msg) =>
showNotice.error(
'settings.modals.clashPort.messages.automaticFallbackFailed',
{ error: msg },
),
}
export const handleNoticeMessage = (
status: string,
msg: string,
t: TranslateFunction,
navigate: NavigateFunction,
) => {
const handlers: Record<NoticeStatus, () => void> = {
info: () => {
if (msg) showNotice.info(msg)
},
'import_sub_url::ok': () => {
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
// navigate("/profile", { state: { current: msg } });
navigate('/profile')
showNotice.success(
'shared.feedback.notifications.importSubscriptionSuccess',
)
},
'import_sub_url::error': () => {
navigate('/profile')
showNotice.error(msg)
},
'set_config::error': () => showNotice.error(msg),
'core_start::error': () => {
const read = ++startupErrorReads
void getCoreStartupError()
.then(async (failure) => {
if (read < settledStartupErrorRead) return
settledStartupErrorRead = read
if (!failure) {
shownStartupError = null
return
}
const window = getCurrentWindow()
const [visible, minimized] = await Promise.all([
window.isVisible(),
window.isMinimized(),
])
const shown = `${failure.kind}:${failure.detail}`
if (
read === settledStartupErrorRead &&
visible &&
!minimized &&
shownStartupError !== shown
) {
shownStartupError = shown
showNotice.error(
failure.kind === 'serviceCoreStopped'
? 'settings.feedback.errors.clash.serviceCoreStopped'
: 'settings.feedback.errors.clash.startFailed',
failure.detail,
)
}
})
.catch((error) => {
console.error('Failed to read the pending core startup error', error)
})
},
'service_core::repair_required': () => {
void takeServiceRepairNotice()
.then((pending) => {
if (pending) {
requestService({ reason: 'serviceLocationRefused' })
}
})
.catch((error) => {
console.error(
'Failed to read the pending service repair notice',
error,
)
})
},
'service_core::app_data_not_owned': () => {
void takeServiceOwnerNotice()
.then((command) => {
if (command) {
if (shownOwnerNotice !== null) hideNotice(shownOwnerNotice)
shownOwnerNotice = showNotice.warning(
'settings.feedback.notifications.clashService.appDataNotOwned',
{ command },
0,
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service owner notice',
error,
)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((notice) => {
if (notice?.kind === 'coreRejected') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: notice.reason },
0,
)
} else if (notice?.kind === 'notAutoStarted') {
requestService({ reason: 'serviceNotAutoStarted' })
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice()
.then((pending) => {
if (pending) {
showNotice.info('settings.modals.dns.protection.autoDisabled')
}
})
.catch((error) => {
console.error('Failed to read the pending DNS override notice', error)
})
},
'enhance::discarded_keys': () => {
void takeDiscardedKeysNotice()
.then((keys) => {
if (keys) {
showNotice.warning('profiles.page.feedback.notices.discardedKeys', {
keys,
})
}
})
.catch((error) => {
console.error(
'Failed to read the pending discarded keys notice',
error,
)
})
},
'tun_mode::auto_disabled': () =>
showNotice.info(
'settings.sections.system.notifications.tunMode.autoDisabled',
),
'tun_mode::auto_disable_failed': () =>
showNotice.error(
'settings.sections.system.notifications.tunMode.autoDisableFailed',
),
'app_restart::core_stop_failed': () =>
showNotice.error(
'layout.feedback.errors.restartCoreStopFailed',
msg || undefined,
),
'app_quit::core_stop_failed': () =>
showNotice.error(
'layout.feedback.errors.quitCoreStopFailed',
msg || undefined,
),
update_with_clash_proxy: () =>
showNotice.success(
'settings.feedback.notifications.updater.withClashProxySuccess',
msg,
),
update_failed_even_with_clash: () =>
showNotice.error(
'settings.feedback.notifications.updater.withClashProxyFailed',
msg,
),
'reactivate_profiles::error': () => showNotice.error(msg),
update_failed: () => showNotice.error(msg),
'config_validate::boot_error': () =>
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
'config_validate::error': () =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::process_terminated': () =>
showNotice.error('shared.feedback.validation.config.processTerminated'),
'config_validate::script_error': () =>
showNotice.error('shared.feedback.validation.script.fileError', msg),
'config_validate::script_syntax_error': () =>
showNotice.error('shared.feedback.validation.script.syntaxError', msg),
'config_validate::script_missing_main': () =>
showNotice.error('shared.feedback.validation.script.missingMain', msg),
'config_validate::file_not_found': () =>
showNotice.error('shared.feedback.validation.script.fileNotFound', msg),
'config_validate::yaml_syntax_error': () =>
showNotice.error('shared.feedback.validation.yaml.syntaxError', msg),
'config_validate::yaml_read_error': () =>
showNotice.error('shared.feedback.validation.yaml.readError', msg),
'config_validate::yaml_mapping_error': () =>
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
'config_validate::merge_syntax_error': () =>
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
'config_validate::merge_mapping_error': () =>
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
'config_core::change_success': () =>
showNotice.success(
'settings.feedback.notifications.clash.changeSuccess',
msg,
),
'config_core::change_error': () =>
showNotice.error(
'settings.feedback.notifications.clash.changeFailed',
msg,
),
'mixed_port::fallback': () => {
const [oldPort, newPort] = msg.split(',')
showNotice.info('settings.modals.clashPort.messages.automaticFallback', {
oldPort,
newPort,
})
},
'mixed_port::fallback_error': () =>
showNotice.error(
'settings.modals.clashPort.messages.automaticFallbackFailed',
{ error: msg },
),
}
const handler = handlers[status as NoticeStatus]
if (handler) {
handler()
handler(msg, navigate)
} else {
console.warn(`未处理的通知状态: ${status}`)
}
+1
View File
@@ -66,6 +66,7 @@ const withLoading = (
next.delete(uid)
}
}
if (next.size === state.profileUpdates.loading.size) return state
return {
...state,
profileUpdates: { ...state.profileUpdates, loading: next },