Compare commits

..
59 Commits
Author SHA1 Message Date
wonfen 4337bd8575 fix(core): clarify and localize existing core errors 2026-10-09 10:03:12 +08:00
cb751052a4 feat(tray): add a per-group latency test (#8173)
* feat(tray): add a per-group latency test

The tray's proxy group submenus offered no way to test a group: refreshing
the delays shown there meant opening the dashboard and running a test from
the proxy page.

Add a "Test Latency" entry at the head of each group submenu. It measures
the group through the proxy page's own view, so member resolution and
provider attribution match the page exactly: provider nodes are health
checked on their provider, anything else goes through the proxy delay
endpoint, and members the page cannot resolve are skipped. Nested groups
are therefore tested through their currently selected node, as the page
does. The menu is rebuilt when the run finishes.

The test URL follows the page's chain -- the group's own test URL, then the
configured default, then the built-in one -- including the page's validity
check, so a group with a malformed URL falls back where the page falls back.

A group test started from the app rebuilds the tray as well, so the two
views do not drift.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(tray): keep latency tests and displayed results consistent

* fix(tray): isolate latency histories and preserve usable menus

* fix(tray): keep settings usable when proxy reads fail

* fix(tray): show selected node latency for proxy groups and display timeouts as T/O

Co-authored-by: wonfen <eiwonfen@gmail.com>

* fix(tray): refresh proxy latency on menu interaction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-10-09 09:11:07 +08:00
MrHardand❤是纱雾酱哟~ d9dc18a57b fix(profiles): cap the profile grid at four columns (#8068)
* fix(profiles): cap the profile grid at four columns

The move to CSS grid in #7819 let wide windows fit six or more profile cards per row, so long profile names were cut off. Keep the 260px minimum width but never use more than four columns, as the old Grid layout did.

* fix(profiles): let very wide windows use more than four columns

---------

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-08 16:31:57 +00:00
6b4d4e1551 feat(i18n): add Brazilian Portuguese locale (#8203)
Add the `pt` frontend and backend locale bundles, register the language in
the language selector, and add a Portuguese README linked from the other
READMEs.

Co-authored-by: Nicolas Pastorello <opastorello@MacBookPro.pastorello.infra>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 12:48:30 +08:00
wonfen 4a125681bd fix(windows): pin muda fork to prevent tray menu command ID exhaustion 2026-10-08 11:14:13 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 1c2817023e chore(deps): update dependency pnpm to v12.10.0 (#8194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 17:00:57 +00:00
Tunglies 89076f01c3 refactor(rust): adopt stable APIs through Rust 1.99 (#8193) 2026-10-07 16:20:33 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
a4531cf926 chore(deps): update cargo dependencies (#8189)
* chore(deps): update cargo dependencies

* build(deps): revert "rfd" from version 0.17.2 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-07 15:18:45 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 07b0e71cc6 chore(deps): update dependency ubuntu to v26 (#8192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 13:03:24 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ad2baf254a chore(deps): update dependency webdriverio to v10 (#8191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 12:08:37 +00:00
Tunglies d01f43cc78 refactor(deps): replace once_cell with std OnceLock and LazyLock (#8136) 2026-10-07 07:38:56 +00:00
Tunglies 720dc13e96 refactor(backend): downgrade lock (#8135)
* refactor(updater): drop write-only pending_* slots

* refactor(notify): fold sequence counter into entries lock

* refactor(port): fold startup-core-blocked flag into reason slot

* refactor(service): drop redundant provider sync serial lock

* refactor(port): store startup block reason in a OnceLock

* refactor(core): replace macOS network monitor slot with armed latch
2026-10-07 07:38:56 +00:00
Tunglies 69b76cc6a9 refactor(frontend): contract flow (#8131)
* feat(contract): export wire contract from backend and generate frontend types

Adds a zero-dependency `frontend-contract` bin that serializes the real
NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs
which emits the committed src/services/contract.ts, and a CI job that
regenerates it and fails on a non-empty diff. events.ts now derives its
event map from the generated contract instead of hand-typed shapes.

* feat(events): route all backend events through a single event bus and store

Adds src/services/bus (the only listen/emit site: one handler per contract
event plus the frontend-internal test-all event) and the React
context/reducer app store. Migrates the five scattered subscriptions
(layout events, pending failures, proxy refresh, profile update/timer,
test-all), deletes the old glue (events.ts, use-listen, use-layout-events,
LoadingCacheProvider), moves run state into the store, and adds an
architecture gate script wired into CI. Behavior is unchanged; the
run-state query's poll/focus safety net is preserved by the store
provider.

* refactor(notice): make the notice table exhaustive and drop dead wire entries

D5 cleanup, frontend and backend in one commit: removes the six
config_validate::* statuses that never receive a backend send and the
set_config::ok send (enum variants, golden list 41→34, send point in
restart_clash_core), their frontend handlers, and the five i18n keys only
those handlers used. The notice table is now typed as a Record over the
generated NoticeStatus union, so a status without a frontend entry fails
to compile. The info status (sent by the silent updater for breaking
change notices, previously dropped with a console warning) now shows an
info toast — the one deliberate behavior delta.

* feat(mutate): single funnel for user-intent commands and invoke gate

Adds src/services/mutate.ts: one path for optimistic apply, services-layer
invoke, Busy handling, failure rollback, and the error toast, with busy
bookkeeping mirrored into the app store. The patch hooks (verge, clash,
profiles) now route through it. Moves the remaining direct invokes
(unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services
architecture gate in CI.

* refactor(settings): route settings-domain commands through the mutate funnel

Ports port saving, webdav/local backup operations, core change/restart/
upgrade, service install/uninstall/repair flows, the system-proxy toggle
queue, the DNS override toggle, and app lifecycle commands onto the single
funnel. Bespoke flows keep their own error handling via errorNotice:false;
simple flows gain the funnel's default error toast and revalidation.

* refactor(profiles): route profile operations through the mutate funnel

Ports profile create/import/reorder/delete/update/patch/save-file and
enhance calls onto the funnel; retry and editor flows keep their own
error handling with errorNotice:false. Per-uid ids keep the update
worker pool concurrent under the funnel's in-flight dedupe.

* refactor(proxies): route proxy/home/misc commands through the mutate funnel

Ports mode switching (page and home card), chain runtime updates, node
selection with tray sync, selection recording, provider updates and
syncs, and lightweight-mode entry onto the funnel; every site keeps its
existing optimistic/revert and error-handling behavior.

* docs: add frontend architecture guardrails for src

Layering rules for the contract flow: generated contract as the only
wire-typing source, event bus as the only listen/emit site, pure-reducer
store for event-derived state, the mutate funnel for user intent, and
invoke only in the services layer — each backed by the CI gates.

* fix(events): close run-state race and restore single error toasts

Self-review findings: the bus now re-reads the run-state snapshot in
onSubscribed so an event racing the store's initial read cannot leave a
stale snapshot (the ordering the old layout listener guaranteed), and the
patch hooks pass errorNotice:false so GuardState/page handlers remain the
only error toast, matching the pre-migration behavior.

* fix(frontend): preserve queued intents and fresh runtime snapshots

* refactor(frontend): trim unused mutation and busy state APIs

* docs(frontend): focus guardrails on architecture ownership
2026-10-07 07:38:55 +00:00
Tunglies 8328e34208 refactor(backend): declarative effects (#8128)
* refactor(core): type notice statuses behind the frozen wire contract

* refactor(utils): unified retry and strategy-list helpers

* refactor(feat): declarative effect registry for verge and clash patches

* refactor(core): derive refresh announcements from transaction commit

* refactor(core): fold tray, hotkey, autostart updates into effect ensures

* refactor(utils): migrate remaining bounded retry loops

* docs: add src-tauri architecture guardrails

* refactor(core): coalesce burst refresh announcements

* refactor(core): complete bounded service retry migration

* ci(backend): add architecture ban gates

* test(feat): freeze representative effect registry mappings

* docs(backend): record dead wire contract entries

* chore(core): document watcher exit conditions

* fix(backend): preserve applied state across later failures

* fix(config): retain runtime after an applied core replacement

* refactor(backend): flatten refresh publication and retry policies

* docs(backend): focus guardrails on architecture ownership

* refactor(effects): use one execution order

* fix(config): publish retained runtime and reconcile proxy settings
2026-10-07 07:38:55 +00:00
❤是纱雾酱哟~ 78691ea5c8 fix(updater): clear cache when auto-check is disabled (#8091)
Assisted-by: wonfen
2026-10-07 13:16:34 +08:00
OrangeJuice af808a677c chore(i18n): translate English leftovers in German locale (#8184) 2026-10-07 10:39:03 +08:00
wonfen a1bebf6de1 fix(exit): bound session-ending cleanup with a hard deadline
Refs #8180
2026-10-06 23:18:17 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 2caff762b5 chore(deps): update dependency pnpm to v12.9.1 (#8181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 12:44:47 +00:00
wonfen dba0f471bf fix(updater): retry slow update downloads from other sources and honour cancel 2026-10-06 14:21:09 +08:00
wonfen c341b9b238 fix(updater): install the already downloaded update instead of fetching it again 2026-10-06 14:21:09 +08:00
wonfen 6ec35c8195 fix(macos): restore system DNS reliably after TUN
Detached tasks spawned on every config generation set and restored the
placeholder DNS without ordering, so a set could back up the placeholder
itself and a later restore reported success while leaving it in place.

Reconcile from the applied config under the lifecycle lock, and restore
whenever the core stops. Keep one backup per network service, never back
up the placeholder, and restore only while the service still holds it.
Scripts verify every read and write and are killed after 5 s.

Refs #8070, #8168
2026-10-06 12:47:08 +08:00
wonfen 30f05a7a39 refactor(port): build the port dialog rows from one listener table 2026-10-05 22:00:54 +08:00
wonfen 7cc6c1ed95 fix(port): edit the saved mixed port, not the session fallback
Closes #8149
2026-10-05 22:00:54 +08:00
wangentongee-cmyk c7cee50fc1 docs: 补充中文 README 的系统支持说明 (#8158)
中文部分缺少支持的系统说明,已对照英文补充。
2026-10-05 13:31:19 +00:00
BadCat1428 4cae1ff132 fix a wrong in README.md(强迫症犯了) (#8176)
* Update README.md

* Update README.md
2026-10-05 13:12:38 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 1a01844cb8 chore(deps): update npm dependencies (#8155)
* chore(deps): update npm dependencies

* build(deps): align tauri-related dependencies

- Tauri-related dependencies have to be aligned between Rust and Nodejs

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>
2026-10-03 17:09:11 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
1575f134e0 chore(deps): update cargo dependencies (#7961)
* chore(deps): update cargo dependencies

* build(deps): revert rfd from 0.17.2 to 0.16.0

- This also prevents Mend Renovate from auto rebase

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

* fix(nsis): include missing Nullsoft headers

- Transitive macro named `RestartManager_StartSession` cannot be found
  during NSIS installer generation
- The macro comes from "RestartManager" plugin and should be explicitly
  included in the `installer.nsi` file

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-03 17:09:08 +00:00
wonfen a9a3cfa3a7 fix(proxy): resolve provider members within each group's providers
Same-name nodes from different proxy-providers were marked ambiguous and
greyed out. Resolve each group member against the group's own providers
(use / include-all, exclude-type) and bind the n-th occurrence of a name to
the n-th candidate, matching Mihomo.

Closes #8021
2026-10-03 22:49:39 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
14770df157 chore(deps): update rust toolchain to v1.99.0 (#8147)
* chore(deps): update rust toolchain to v1.99.0

* fix(rust): handle breaking changes introduced by compiler

- Trailing semicolon in macro should not be used in expression position
- A deprecated method was renamed for consistency
  - Before: `std::sync::atomic::Atomic::<u64>::fetch_update`
  - After: `std::sync::atomic::Atomic::<u64>::try_update`

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-03 12:31:08 +00:00
wonfen 57c5484080 fix(service): stop waiting on a service that no longer starts with Windows
When the service's start type was changed from automatic, startup waited
about two minutes for it before falling back to Sidecar. Detect this
immediately, skip the TUN readiness wait, and offer a reinstall that
restores automatic start.

Refs #8129
2026-10-03 20:40:51 +08:00
wonfen 4a0882fc18 fix(singleton): repair Windows lock file permissions in place 2026-10-03 15:59:39 +08:00
wonfen d87bb7b6a3 fix(singleton): acknowledge window restoration before UI work
Dispatch blocking window restoration outside HTTP workers to prevent notification timeouts and repeated window activation in #8118. Remove the misleading failure log, update the changelog, and remove server tests as requested.
2026-10-03 10:56:41 +08:00
wonfen a58cefdc31 fix(notice): keep code-based dismissal internal 2026-10-03 10:30:00 +08:00
wonfen f958dccb25 fix(service): explain sidecar failures and clear recovered notices 2026-10-03 10:21:54 +08:00
Christine. 8c1fb3ac56 feat: set Tun.stack to mips (#8144) 2026-10-03 01:53:52 +00:00
Tunglies 3607e66bb3 perf(traffic): reuse canvas pixels while scrolling 2026-10-03 06:58:54 +08:00
wonfen ea509b8236 chore(deps): pin clash-verge-service-ipc to v2.7.6 2026-10-02 22:57:11 +08:00
wonfen 52d616302c fix(service): explain a startup core rejection instead of the generic fallback
When the startup probe finds the service refusing the approved core, the
app falls back to Sidecar with only the generic "could not start the
core" notice. The reason and repair guide appeared only after a
reinstall, which cannot fix a writable volume root (#8045).

Carry the fallback reason to the window and show the same permission
notice as a rejected installation when the core was refused.

Refs #8045
2026-10-02 10:38:09 +08:00
wonfen 8ddd99f226 chore: bump version v2.5.7 2026-09-30 22:07:17 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e0e97a920e chore(deps): update dependency pnpm to v12.8.1 (#8127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-30 06:27:19 +00:00
wonfen c4f9d65e07 fix(service): don't ask to repair the service when the proxy clear fails 2026-09-29 10:09:21 +08:00
wonfen 0c7e0c8535 fix(dns): persist DNS override confirmation across restarts
A confirmed override on a profile with provider DNS was turned off at
every startup; it now stays on until that profile's provider DNS changes.

Refs #7993
2026-09-28 13:09:09 +08:00
mrhard9090andClaude Opus 5.5 1638ab48d8 fix(layout): don't expose locked sidebar items as draggables (#8064)
While the menu order is locked, the sidebar items were still registered as
disabled sortables, so dnd-kit gave each nav button a draggable role
description, aria-disabled=true, aria-pressed and the drag instructions.
Screen readers announced every item as an unavailable draggable toggle.
Only wrap the items in SortableItem while reordering is unlocked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:37:54 +00:00
❤是纱雾酱哟~ 5d7dc3aed9 chore(gh-aw): Bump gh-aw to version v0.89.21 (#8072)
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-26 09:51:01 +00:00
wonfen b057bd964c fix clippy 2026-09-26 00:00:50 +08:00
wonfen 897a117dc5 chore(deps): bump tauri-plugin-mihomo for provider update timeouts
Pick up tauri-plugin-mihomo@ba8434c: provider updates now wait out
mihomo's 20s fetch instead of failing at 5s with "error sending request",
so slow subscriptions update and real failures show mihomo's own error.

Refs #8046
2026-09-25 16:09:57 +08:00
wonfen 6a752994cf fix(service): fail fast when the Windows service is stopped
A registered but stopped service made every version probe back off for
about two minutes, blocking startup before the window and core (#8059).
Check the SCM first; a service that is starting, or not yet started this
boot, still goes through the IPC retries.
2026-09-25 11:55:01 +08:00
wonfen 32e5d5bda3 chore(deps): pin clash-verge-service-ipc to v2.7.4
Replace the local path pin from fbc4b516 with the release tag, which
ships the installer group lookup fix (#8043) along with the residual
helper (#8031) and app data owner (#8026) service changes.
2026-09-25 09:09:14 +08:00
wonfen daae17a40c fix(service): surface a service core that stopped for good
When the service watchdog gave up or the core vanished, the owner
monitor marked the core stopped without telling the user, and on macOS
left the system proxy on the dead mixed port (#8029).

- On SameOwnerFailure, clear the proxy through the helper while the
  session is still ours; displaced or unreachable owners still leave the
  machine-wide setting alone.
- Retain the stop as a core failure with its own message, recorded
  under the lifecycle lock and shown now or on next focus; a running
  core resets the notice dedup.
- Log each watchdog restart the service reports, since launchd discards
  the service's own log.
2026-09-25 00:06:59 +08:00
wonfen 1f140dfd3a fix(service): show how to reclaim an app data root the service refuses
When the service refuses the app data root because another uid owns it
(code 1012), show a sticky notice with the chown command for this
account. Repairing the service cannot fix this.

Refs #8026
2026-09-24 22:10:42 +08:00
wonfen 04d74ac48f fix(service): offer repair when a residual helper blocks Sidecar
A macOS helper left running without IPC made Sidecar refuse to start
while the service counted as not installed, so no repair was offered
(#8031, #8043). Treat such a helper as install evidence and record the
Sidecar refusal as unavailable so the repair dialog appears.
2026-09-24 20:24:40 +08:00
wonfen 78bf79cc09 chore: update changelog 2026-09-24 18:52:24 +08:00
wonfen 61c33c6113 fix(core): retain startup diagnostics until recovery
Show unresolved startup failures when the window becomes visible and clear them when either core mode starts successfully. Cover recovery followed by exit and WebView recreation, use neutral service permission wording, and document the Windows startup fix.
2026-09-24 18:43:28 +08:00
wonfen ae4f416766 fix(service): explain core security failures and link repair guidance 2026-09-24 18:43:27 +08:00
wonfen fbc4b516b8 fix(service): fall back after installation verification rejects cores 2026-09-24 18:43:26 +08:00
3456de1fb6 fix(rules): disable auto-capitalization in rule content input (#8042)
* fix(rules): disable auto-capitalization in rule content input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rules): pass input hints to the rule content input element

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-09-24 15:49:19 +08:00
Tunglies f9ef9d78cb chore: bump version to 2.5.6 2026-09-24 15:25:05 +08:00
Tungliesand❤是纱雾酱哟~ 3458c9b075 fix(layout): restore "New" label in update button
Closes #8000

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-24 15:23:35 +08:00
❤是纱雾酱哟~ afc32f568a chore(deps): Bump pnpm and cargo dependencies (#8022)
- Bump dependencies via `pnpm update`
- Bump dependencies via `cargo upgrade`
  - Incompatible dependencies already ignored
- Align Tauri-related dependencies version between `pnpm` and `cargo`
- Drop explicit forbidden of application downgrades

Assisted-by: vscode-copilot-chat:copilot:gpt-5.6-luna codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-23 07:03:15 +00:00
279 changed files with 11948 additions and 5632 deletions
+2 -2
View File
@@ -5,10 +5,10 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.5.1"
"pnpmVersion": "12.10.0"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1",
"version": "1.99.0",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+26 -11
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.15": {
"github/gh-aw-actions/setup@v0.89.21": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.15",
"sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
"version": "v0.89.21",
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
}
},
"containers": {
@@ -27,6 +27,11 @@
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -42,6 +47,11 @@
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -57,20 +67,25 @@
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
},
"ghcr.io/github/github-mcp-server:v1.12.1": {
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
"ghcr.io/github/github-mcp-server:v1.12.2": {
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
}
}
}
+7 -7
View File
@@ -101,7 +101,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.98.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.98.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -580,7 +580,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+26
View File
@@ -0,0 +1,26 @@
name: Backend Guardrails
on:
pull_request:
paths:
- "src-tauri/**"
- ".github/workflows/backend-guardrails.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
banned-patterns:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: No Handle::refresh calls in feat
run: '! grep -rn "Handle::refresh" src-tauri/src/feat'
- name: No notice_message outside core/notify
run: '! grep -rn "notice_message(" src-tauri/src --include="*.rs" | grep -v "core/notify"'
- name: No manual update-flag chains
run: '! grep -rnE "determine_update_flags|UpdateFlags" src-tauri/src'
+1 -1
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
with:
version: v0.89.15
+2 -2
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.98.1
uses: dtolnay/rust-toolchain@1.99.0
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -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.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
+79
View File
@@ -85,3 +85,82 @@ jobs:
- 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: |
contract:
- 'src/services/contract.ts'
- 'scripts/gen-contract.mjs'
- 'src-tauri/src/core/notify/**'
- 'src-tauri/src/bin/**'
- 'src-tauri/src/lib.rs'
- '.github/workflows/frontend-check.yml'
- 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: Regenerate contract and require empty diff
if: steps.check_inputs.outputs.contract == 'true'
run: |
pnpm gen:contract
git diff --exit-code -- src/services/contract.ts
File diff suppressed because one or more lines are too long
+8 -8
View File
@@ -144,7 +144,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.98.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.98.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.98.1'
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -691,7 +691,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.5.1
rust 1.98.1
pnpm 12.10.0
rust 1.99.0
Generated
+276 -214
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -47,6 +47,9 @@ codegen-units = 1
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" }
# macOS Golden Gate fix for tray-icon click reentrancy issue
# Remove the following patch once the fix is merged upstream(tauri)
# https://github.com/tauri-apps/tray-icon/pull/365
@@ -63,13 +66,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.11.5" }
tauri-plugin-clipboard-manager = "2.3.3"
tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.4.1"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.1", features = [
tokio = { version = "1.53.2", features = [
"rt-multi-thread",
"macros",
"time",
+34 -19
View File
@@ -1,36 +1,51 @@
## v2.5.5
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🐧 Linux**
**🍎 macOS**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
+7 -5
View File
@@ -11,13 +11,14 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
<p align="center">
Languages:
<a href="./README.md">简体中文</a> ·
<a href="./docs/README_en.md">English</a> ·
<a href="./docs/README_es.md">Español</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./docs/README_fa.md">فارسی</a> ·
<a href="./docs/README_ja.md">日本語</a> ·
<a href="./docs/README_ko.md">한국어</a> ·
<a href="./docs/README_fa.md">فارسی</a>
<a href="./docs/README_pt.md">Português</a> ·
<a href="./docs/README_ru.md">Русский</a> ·
<a href="./README.md">简体中文</a>
</p>
## Preview
@@ -31,6 +32,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
#### 我应当怎样选择发行版
@@ -50,14 +52,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
#### AI云边 -- 全新架构机场。
- 💻 多次**技术迭代后**全新亮相。
- 🗺 全**高速稳定**正价节点。
- 🌏 **海外团队**,不跑路
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
- 👨‍🦲 团队架构师为**大厂**网络架构师
- 💰 极致**稳定**,亲民价**价格**
- 🌐 全面支持**流媒体及各AI访问**
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "4.2.2"
rust-i18n = "4.2.4"
sys-locale = "0.3.2"
[lints]
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
core:
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
@@ -68,6 +70,8 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
+12 -8
View File
@@ -8,8 +8,8 @@ notifications:
body: Auf {mode} umgeschaltet.
systemProxyToggled:
title: Systemproxy
'on': System proxy has been enabled.
'off': System proxy has been disabled.
'on': Der Systemproxy wurde aktiviert.
'off': Der Systemproxy wurde deaktiviert.
systemProxyFailed:
title: Systemproxy
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum.
@@ -18,8 +18,8 @@ notifications:
body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
tunModeToggled:
title: TUN-Modus
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
'on': Der TUN-Modus wurde aktiviert.
'off': Der TUN-Modus wurde deaktiviert.
lightweightModeEntered:
title: Leichtmodus
body: Leichtmodus aktiviert.
@@ -30,13 +30,15 @@ notifications:
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
title: Clash-Verge-Update
body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
installNow: Jetzt installieren
later: Später
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
core:
alreadyRunning: 'Der Kern kann nicht gestartet werden: Ein Kernprozess läuft bereits. Beenden Sie die zugehörige App oder den verbliebenen Prozess und versuchen Sie es erneut. Wenn das Problem weiterhin besteht, starten Sie den Computer neu.'
launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
core:
alreadyRunning: 'Cannot start the core: a core process is already running. Close the related app or stop the leftover process, then retry. If the problem persists, restart your computer.'
launchGuard:
currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
core:
alreadyRunning: 'No se puede iniciar el núcleo: ya hay un proceso del núcleo en ejecución. Cierre la aplicación correspondiente o finalice el proceso restante y vuelva a intentarlo. Si el problema persiste, reinicie el equipo.'
launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
core:
alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.'
launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
@@ -68,6 +70,8 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
core:
alreadyRunning: 'Tidak dapat memulai inti: proses inti sudah berjalan. Tutup aplikasi terkait atau hentikan proses yang tersisa, lalu coba lagi. Jika masalah berlanjut, mulai ulang komputer.'
launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
core:
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
@@ -68,6 +70,8 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
core:
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
@@ -68,6 +70,8 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
+92
View File
@@ -0,0 +1,92 @@
_version: 1
notifications:
dashboardToggled:
title: Painel
body: A visibilidade do painel foi atualizada.
clashModeChanged:
title: Troca de modo
body: Alterado para {mode}.
systemProxyToggled:
title: Proxy do sistema
'on': O proxy do sistema foi ativado.
'off': O proxy do sistema foi desativado.
systemProxyFailed:
title: Proxy do sistema
body: Não foi possível definir o proxy do sistema. Abra o Clash Verge para ver o motivo.
systemProxyGuardStopped:
title: Proteção do proxy do sistema
body: A proteção parou após várias falhas de execução. Abra o Clash Verge para ver o motivo.
tunModeToggled:
title: Modo TUN
'on': O modo TUN foi ativado.
'off': O modo TUN foi desativado.
lightweightModeEntered:
title: Modo leve
body: Modo leve ativado.
profilesReactivated:
title: Perfis
body: Perfil reativado.
appQuit:
title: Prestes a sair
body: O Clash Verge está prestes a sair.
updateReady:
title: Atualização do Clash Verge
body: Uma nova versão (v{version}) foi baixada e está pronta para instalar.
installNow: Instalar agora
later: Depois
service:
adminInstallPrompt: A instalação do serviço do Clash Verge requer privilégios de administrador.
adminUninstallPrompt: A desinstalação do serviço do Clash Verge requer privilégios de administrador.
core:
alreadyRunning: 'Não é possível iniciar o núcleo: já há um processo do núcleo em execução. Feche o aplicativo correspondente ou encerre o processo remanescente e tente novamente. Se o problema persistir, reinicie o computador.'
launchGuard:
currentExeError: 'Não foi possível determinar a localização do aplicativo: {error}'
homeDirError: 'Não foi possível determinar o diretório pessoal do usuário atual. O aplicativo será encerrado: {error}'
translocated: O macOS está executando este aplicativo a partir de um caminho temporário de App Translocation. No Finder, mova-o manualmente para /Applications ou ~/Applications e abra-o novamente.
rejected: |-
Este aplicativo deve ser iniciado a partir de um diretório de Aplicativos.
{reason}
notInBundle: O executável não está dentro de um pacote de aplicativo.
canonicalizeBundleError: 'Não foi possível resolver o caminho do pacote do aplicativo: {error}'
bundleNameError: Não foi possível determinar o nome do pacote do aplicativo. O aplicativo será encerrado.
replacePrompt: Já existe um aplicativo em {destination}. Substituí-lo e reabrir a partir de Aplicativos?
movePrompt: Para iniciar o serviço em segundo plano com segurança, o aplicativo precisa ser movido para {destination}. Mover e reabrir agora?
createApplicationsDirError: 'Não foi possível criar o diretório de Aplicativos: {error}'
moveFailed: Falha ao mover o aplicativo. Mova-o manualmente no Finder e tente novamente.
replaceFailed: 'Não foi possível substituir o aplicativo existente com segurança: {error}'
relaunchFailed: O aplicativo foi copiado, mas não pôde ser reaberto automaticamente. Abra-o manualmente a partir de Aplicativos.
cancel: Cancelar
move: Mover
ok: OK
tray:
dashboard: Painel
ruleMode: Modo regra
globalMode: Modo global
directMode: Modo direto
outboundModes: Modos de saída
rule: Regra
direct: Direto
global: Global
profiles: Perfis
proxies: Proxies
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
lightweightMode: Modo leve
copyEnv: Copiar variáveis de ambiente
confDir: Diretório de configuração
coreDir: Diretório do núcleo
logsDir: Diretório de logs
openDir: Abrir diretório
appLog: Log do aplicativo
coreLog: Log do núcleo
restartClash: Reiniciar o núcleo do Clash
restartApp: Reiniciar o aplicativo
vergeVersion: Versão do Verge
more: Mais
exit: Sair
tooltip:
systemProxy: Proxy do sistema
tun: TUN
profile: Perfil
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
core:
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
@@ -68,6 +70,8 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
core:
alreadyRunning: 'Çekirdek başlatılamıyor: bir çekirdek işlemi zaten çalışıyor. İlgili uygulamayı kapatın veya kalan işlemi sonlandırıp yeniden deneyin. Sorun devam ederse bilgisayarı yeniden başlatın.'
launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
@@ -68,6 +70,8 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
core:
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
@@ -68,6 +70,8 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
core:
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
launchGuard:
currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
@@ -68,6 +70,8 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
+4
View File
@@ -37,6 +37,8 @@ notifications:
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
core:
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
launchGuard:
currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
@@ -68,6 +70,8 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
@@ -14,7 +14,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.189"
libc = "0.2.190"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
+120
View File
@@ -1,5 +1,114 @@
## v2.5.7
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
**🖥️ Windows**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS**
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
</details>
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🍎 macOS**
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
## v2.5.5
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
</details>
## v2.5.4
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
@@ -45,6 +154,11 @@
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
@@ -66,6 +180,11 @@
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
@@ -90,6 +209,7 @@
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
+7 -6
View File
@@ -11,13 +11,14 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
<p align="center">
Languages:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Preview
@@ -51,11 +52,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
## Promotion
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### **Core Advantages:**
@@ -67,7 +68,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
- 🎬 Unlocks **streaming platforms and mainstream AI services**
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
+7 -6
View File
@@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
<p align="center">
Idiomas:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Vista previa
@@ -51,17 +52,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
## Promociones
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
+7 -6
View File
@@ -11,13 +11,14 @@
<p align="center">
زبان‌ها:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## پیش‌نمایش
@@ -50,17 +51,17 @@
## تبلیغات
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
- بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K
- اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
+7 -6
View File
@@ -11,13 +11,14 @@
<p align="center">
言語:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## プレビュー
@@ -51,17 +52,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
## プロモーション
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
+7 -6
View File
@@ -11,13 +11,14 @@
<p align="center">
언어:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## 미리보기
@@ -51,17 +52,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
## 프로모션
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
+125
View File
@@ -0,0 +1,125 @@
<h1 align="center">
<img src="../src-tauri/icons/icon.png" alt="Clash" width="128" />
<br>
Continuação do <a href="https://github.com/zzzgydi/clash-verge">Clash Verge</a>
<br>
</h1>
<h3 align="center">
Uma interface gráfica para o Clash Meta construída com <a href="https://github.com/tauri-apps/tauri">Tauri</a>.
</h3>
<p align="center">
Idiomas:
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Pré-visualização
| Escuro | Claro |
| -------------------------------------- | -------------------------------------- |
| ![Visual escuro](./preview_dark.png) | ![Visual claro](./preview_light.png) |
## Instalação
Acesse a [página de lançamentos](https://github.com/clash-verge-rev/clash-verge-rev/releases) e baixe o instalador correspondente à sua plataforma.<br>
Oferecemos pacotes para Windows (x64/x86), Linux (x64/arm64) e macOS 10.15+ (Intel/Apple).
#### Como escolher o canal de lançamento
| Canal | Descrição | Link |
| :---------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------- |
| Stable | Versões oficiais de alta confiabilidade, ideais para o uso diário. | [Release](https://github.com/clash-verge-rev/clash-verge-rev/releases) |
| Alpha (EOL) | Versões antigas usadas para validar o fluxo de publicação. | [Alpha](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/alpha) |
| AutoBuild | Versões contínuas para testes e feedback. Espere mudanças típicas de beta. | [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) |
#### Guias de instalação e perguntas frequentes
Consulte a [documentação do projeto](https://clash-verge-rev.github.io/) para ver os passos de instalação, a solução de problemas e as perguntas frequentes.
### Canal do Telegram
Entre em [@clash_verge_rev](https://t.me/clash_verge_re) para acompanhar as novidades.
---
## Promoções
#### [Doggygo VPN — Acelerador global focado em desempenho](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Serviço internacional de alto desempenho com teste gratuito, planos com desconto, desbloqueio de streaming e suporte de primeira linha ao protocolo Hysteria.
- Cadastre-se pelo link exclusivo do Clash Verge e ganhe um teste de 3 dias com 1 GB de tráfego diário: [Cadastre-se](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Cupom exclusivo de 20% de desconto para usuários do Clash Verge: `verge20` (limitado a 500 usos)
- Plano promocional a partir de ¥15,8 por mês com 160 GB, mais 20% de desconto adicional no pagamento anual
- Equipe sediada no exterior para um serviço confiável, com até 50% de comissão compartilhada
- Clusters balanceados com rotas dedicadas de alta velocidade (compatíveis com clientes antigos), latência extremamente baixa, reprodução 4K sem travamentos
- Primeiro provedor global com **protocolo QUIC**, agora com protocolos da família QUIC mais rápidos (ideal para o cliente Clash Verge)
- Desbloqueia serviços de streaming e acesso ao ChatGPT
- Site oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atendimento ao cliente com IA integrada ao Crisp](https://gptkefu.com)
- 🧠 Compreensão profunda de todo o contexto da conversa + reconhecimento de imagens, com respostas profissionais e precisas de forma automática, sem respostas robóticas.
- ♾️ **Respostas ilimitadas**, sem preocupação com cotas — ao contrário de outros produtos de IA que cobram por mensagem.
- 💬 Consultas de pré-venda, suporte pós-venda, resolução de problemas complexos — cobre todos os cenários com facilidade, com casos reais comprovados.
- ⚡ Configuração em 3 minutos, sem curva de aprendizado — melhora na hora a eficiência e a satisfação do cliente.
- 🎁 Teste gratuito de 14 dias do plano Premium — experimente antes de pagar: 👉 [Testar grátis](https://gptkefu.com)
- 📢 Canal do Telegram do atendimento com IA: [@crisp_ai](https://t.me/crisp_ai)
---
## Funcionalidades
- Baseado em Rust de alto desempenho e no framework Tauri 2
- Inclui o núcleo integrado [Clash.Meta (mihomo)](https://github.com/MetaCubeX/mihomo) e permite trocar para o canal `Alpha`
- Interface limpa e elegante, com controles de cor do tema, ícones de grupos de proxy/bandeja e `CSS Injection`
- Gerenciamento avançado de perfis (ferramentas Merge e Script) com sugestões de sintaxe para as configurações
- Controle do proxy do sistema, modo guardião e suporte a `TUN` (adaptador de rede virtual)
- Editores visuais para nós e regras
- Backup e sincronização via WebDAV
### Perguntas frequentes
Acesse a [página de FAQ](https://clash-verge-rev.github.io/faq/windows.html) para ver instruções específicas por plataforma.
### Doações
[Apoie o desenvolvimento do Clash Verge Rev](https://github.com/sponsors/clash-verge-rev)
## Desenvolvimento
Consulte o [CONTRIBUTING.md](../CONTRIBUTING.md) para ver as diretrizes de contribuição.
Depois de instalar todos os pré-requisitos do **Tauri**, execute o ambiente de desenvolvimento com:
```shell
pnpm i
pnpm run prebuild
pnpm dev
```
## Contribuições
Issues e pull requests são bem-vindos.
## Agradecimentos
O Clash Verge Rev é baseado nos projetos a seguir, ou se inspira neles:
- [zzzgydi/clash-verge](https://github.com/zzzgydi/clash-verge): Interface gráfica para o Clash baseada em Tauri. Compatível com Windows, macOS e Linux.
- [tauri-apps/tauri](https://github.com/tauri-apps/tauri): Crie aplicativos de desktop menores, mais rápidos e mais seguros com um frontend web.
- [Dreamacro/clash](https://github.com/Dreamacro/clash): Túnel baseado em regras escrito em Go.
- [MetaCubeX/mihomo](https://github.com/MetaCubeX/mihomo): Túnel baseado em regras escrito em Go.
- [Fndroid/clash_for_windows_pkg](https://github.com/Fndroid/clash_for_windows_pkg): Interface do Clash para Windows e macOS.
- [vitejs/vite](https://github.com/vitejs/vite): Ferramentas de frontend de nova geração com uma experiência ultrarrápida.
## Licença
Licença GPL-3.0. Consulte o [arquivo de licença](../LICENSE) para mais detalhes.
+7 -6
View File
@@ -11,13 +11,14 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
<p align="center">
Языки:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="./README_fa.md">فارسی</a> ·
<a href="./README_ja.md">日本語</a> ·
<a href="./README_ko.md">한국어</a> ·
<a href="./README_fa.md">فارسی</a>
<a href="./README_pt.md">Português</a> ·
<a href="./README_ru.md">Русский</a> ·
<a href="../README.md">简体中文</a>
</p>
## Предпросмотр
@@ -47,17 +48,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+2 -1
View File
@@ -17,7 +17,8 @@
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"]
"src/types/generated/**": ["exports", "types"],
"src/services/contract.ts": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
+54 -52
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.5",
"version": "2.5.8",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -30,6 +30,8 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"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",
"test": "vitest run",
"test:watch": "vitest",
@@ -49,90 +51,90 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.6.0",
"@tauri-apps/plugin-opener": "2.5.5",
"@tauri-apps/plugin-updater": "2.11.0",
"ahooks": "^3.9.7",
"@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",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
"dayjs": "1.11.23",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"meta-json-schema": "^1.19.31",
"monaco-editor": "^0.57.0",
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.5",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.14",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react-markdown": "10.1.0",
"react-router": "^8.0.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.4.2",
"swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.11.4",
"@tauri-apps/cli": "2.12.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint": "^10.11.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-es-x": "^10.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^10.0.0",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.35.1",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"knip": "6.39.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.0",
"webdriverio": "9.31.9",
"ws": "8.21.3"
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"ws": "8.22.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -144,5 +146,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.5.1"
"packageManager": "pnpm@12.10.0"
}
+1398 -1547
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.98.1"
channel = "1.99.0"
components = ["rustfmt", "clippy"]
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src')
const tauriEventImport = (name) =>
new RegExp(
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
let violations = 0
for (const gate of selected) {
const rule = GATES[gate]
if (!rule) {
console.error(`unknown gate: ${gate}`)
process.exit(2)
}
for (const file of walk(SRC)) {
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
if (rule.allowed(relative)) continue
const lines = fs.readFileSync(file, 'utf8').split('\n')
lines.forEach((line, index) => {
if (rule.patterns.some((pattern) => pattern.test(line))) {
violations += 1
console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
)
}
})
}
}
if (violations > 0) {
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
+91
View File
@@ -0,0 +1,91 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
/**
* Regenerates src/services/contract.ts from the backend wire contract.
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
* src-tauri, which serializes the same enum variants and event payloads the
* app emits at runtime. CI reruns this and fails on a non-empty diff.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const OUT = path.join(ROOT, 'src/services/contract.ts')
const contract = JSON.parse(
execFileSync(
'cargo',
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
{
cwd: path.join(ROOT, 'src-tauri'),
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
},
),
)
if (contract.version !== 1) {
throw new Error(`unsupported wire contract version: ${contract.version}`)
}
/** TS type per event, derived from the serialized sample payload's JSON shape. */
const payloadType = (name, sample) => {
if (sample === null) return 'null'
if (typeof sample === 'string') return 'string'
if (Array.isArray(sample)) {
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
throw new Error(`unexpected array payload for ${name}`)
}
if (typeof sample === 'object') {
const keys = Object.keys(sample)
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
if (name === 'verge://run-state-changed') return 'RunState'
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
}
throw new Error(`unexpected payload for ${name}`)
}
const needsRunState = contract.events.some(
(e) => e.name === 'verge://run-state-changed',
)
if (!needsRunState)
throw new Error('run-state-changed event missing from contract')
const lines = []
lines.push(
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
)
lines.push('// Regen: pnpm gen:contract')
lines.push('')
lines.push("import type { RunState } from './cmds'")
lines.push('')
lines.push(
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
)
lines.push('export type NoticeStatus =')
for (const status of contract.noticeStatuses) {
lines.push(` | ${JSON.stringify(status)}`)
}
lines.push('')
lines.push('/** Event name → payload type for every backend-emitted event. */')
lines.push('export interface VergeEventPayloads {')
for (const event of contract.events) {
const key = JSON.stringify(event.name)
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
}
lines.push('}')
lines.push('')
const content = `${lines.join('\n')}\n`
fs.writeFileSync(OUT, content)
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
{ cwd: ROOT, stdio: 'ignore' },
)
console.log(
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
)
+41 -19
View File
@@ -30,29 +30,51 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
original_dns=$(networksetup -getdnsservers "$hardware_port")
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
is_valid_dns=true
break
backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
if [ -f "$backup" ]; then
rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi
networksetup -setdnsservers "$hardware_port" "$1"
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1
networksetup -setdnsservers "$hardware_port" "$1"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then
echo "empty" >"$backup" || exit 1
fi
+57 -8
View File
@@ -1,13 +1,62 @@
#!/bin/bash
nic=$(route -n get default | grep "interface" | awk '{print $2}')
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
result=0
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
original_dns=$(cat .original_dns.txt)
networksetup -setdnsservers "$hardware_port" $original_dns
rm -rf .original_dns.txt
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
fi
for backup in .original_dns/*; do
[ -f "$backup" ] || continue
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1
continue
fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then
original_dns=$(cat "$backup") || { result=1; continue; }
# Older releases could back up the placeholder itself.
[ "$original_dns" = "$1" ] && original_dns=empty
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
rm -f "$backup" || result=1
done
rmdir .original_dns 2>/dev/null
exit $result
+24
View File
@@ -0,0 +1,24 @@
# Backend Architecture Guardrails
These rules supplement the repository's root `AGENTS.md`.
- **Configuration ownership.** `config` owns source configuration, drafts and
persistence. The effects layer owns patch orchestration. Rollbackable
transactions publish state after persistence and commit; already applied
runtime and independently committed state remain authoritative even when a
later step fails. Preserve that state, its notifications and the error.
- **Effect ownership.** The declarative registry classifies every Verge patch field,
including fields without effects and platform-specific fields. Effects
reconcile toward the requested state with explicit dependencies and ordering.
Configuration rollback does not imply reversal of OS or lifecycle effects.
- **Shared wire contract.** Notifications are typed. Event names, status values
and payload shapes stay compatible with frontend producers and consumers.
- **Synchronization ownership.** Configuration-write serialization precedes
configuration-update ownership, which precedes Core lifecycle ownership.
When both are held, the lifecycle lock precedes the DNS lock. Independent
transactions retain their ownership boundaries; asynchronous work does not
hold synchronous data guards across suspension.
- **Retry ownership.** Bounded retries share backend policy while preserving
attempts, delays and terminal errors. Background monitors own their lifetime
and cancellation separately; waiting for a condition or traversing distinct
candidates is not a retry policy.
+21 -21
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.5"
version = "2.5.8"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
default-run = "clash-verge"
build = "build.rs"
edition = "2024"
rust-version = "1.95"
rust-version = "1.99"
[lib]
name = "app_lib"
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
tauri-build = { version = "2.7.1", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
@@ -66,7 +66,6 @@ dunce = "1.0.5"
nanoid = "0.5"
chrono = { workspace = true }
boa_engine = "0.22.0"
once_cell = { version = "1.21.4", features = ["parking_lot"] }
percent-encoding = "2.3.2"
reqwest = { workspace = true }
regex = { workspace = true }
@@ -74,36 +73,37 @@ 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.3.6"
tauri-plugin-opener = "2.5.5"
tauri-plugin-dialog = "2.7.3"
tauri-plugin-fs = "2.5.2"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.10"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0"
zip = "8.6.0"
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"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.4.0"
tauri-plugin-notification = "2.5.1"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.6.0"
tauri-plugin-http = "2.8.0"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.1", optional = 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" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.3", version = "2.7.3", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.5"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
@@ -111,11 +111,11 @@ bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2.189"
libc = "0.2.190"
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
objc2 = "0.6.4"
objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
"NSDictionary",
@@ -146,9 +146,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 = "2.5.1"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.11.0"
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.13.1"
[dev-dependencies]
criterion = { workspace = true }
+1
View File
@@ -901,6 +901,7 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
+8
View File
@@ -0,0 +1,8 @@
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
//! the payload is derived from the same serializers the app uses at runtime.
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
//! TypeScript artifact.
fn main() {
println!("{}", app_lib::frontend_wire_contract());
}
+26 -2
View File
@@ -1,7 +1,12 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use crate::{
cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent},
feat,
utils::dirs,
};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _};
use tauri::{AppHandle, Manager as _, ipc::Channel};
#[tauri::command]
pub async fn open_app_dir() -> CmdResult<()> {
@@ -44,6 +49,25 @@ pub async fn restart_app() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn install_update(
app_handle: AppHandle,
version: String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
})
.await
.stringify_err()
}
#[tauri::command]
pub fn cancel_update_download() {
SilentUpdater::global().cancel_download();
}
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
+4 -3
View File
@@ -1,4 +1,5 @@
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
use crate::core::notify::NoticeStatus;
use crate::feat;
use crate::utils::{dirs, yaml_emitter};
use crate::{
@@ -56,14 +57,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::notice_message("config_core::change_success", clash_core);
handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
handle::Handle::refresh_clash();
Ok(None)
}
Err(err) => {
let failed = err.context("core changed but failed to restart");
let error_msg: String = format!("{failed:#}").into();
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
}
@@ -72,7 +73,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
Err(err) => {
let error_msg: String = format!("{err:#}").into();
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", error_msg);
handle::Handle::notice(NoticeStatus::ConfigCoreChangeError, error_msg.as_str());
Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::module::lightweight;
use crate::core::lightweight;
use super::CmdResult;
+2 -1
View File
@@ -1,6 +1,7 @@
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles;
use crate::core::notify::NoticeStatus;
use crate::utils::window_manager::WindowManager;
use crate::{
config::{
@@ -282,7 +283,7 @@ async fn handle_update_error<E: std::fmt::Display>(
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
discard_and_restore(current_profile).await?;
let message: String = e.to_string().into();
handle::Handle::notice_message("config_validate::boot_error", message.clone());
handle::Handle::notice(NoticeStatus::ConfigValidateBootError, message.as_str());
Ok(ValidationOutcome::invalid_from_message(message))
}
+72 -32
View File
@@ -4,7 +4,7 @@ use crate::{
config::Config,
core::{
handle::Handle,
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
@@ -12,7 +12,7 @@ use crate::{
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::{
collections::HashSet,
collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
@@ -50,20 +50,87 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.collect()
}
/// Mirrors Mihomo: `include-all`/`include-all-providers` replace `use` with every provider, sorted.
fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope> {
let Some(config) = config else {
return BTreeMap::new();
};
let mut all_providers = config
.get("proxy-providers")
.and_then(|providers| providers.as_mapping())
.into_iter()
.flat_map(|providers| providers.keys())
.filter_map(|name| name.as_str())
.map(str::to_owned)
.collect::<Vec<_>>();
all_providers.sort();
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.as_mapping())
.filter_map(|group| {
let name = group.get("name")?.as_str()?.to_owned();
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
all_providers.clone()
} else {
group
.get("use")
.and_then(|providers| providers.as_sequence())
.into_iter()
.flatten()
.filter_map(|provider| provider.as_str())
.map(str::to_owned)
.collect()
};
let exclude_types = group
.get("exclude-type")
.and_then(|types| types.as_str())
.filter(|types| !types.is_empty())
.map(|types| types.split('|').map(str::to_owned).collect())
.unwrap_or_default();
Some((
name,
GroupScope {
providers,
exclude_types,
},
))
})
.collect()
}
#[tauri::command]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers: providers.ok(),
providers,
}))
}
@@ -84,6 +151,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
}
async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop {
match Tray::global().update_menu().await {
Ok(_) => {
@@ -109,31 +177,3 @@ async fn run_tray_sync_loop() {
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use serde_yaml_ng::Value;
use super::runtime_group_order;
#[test]
fn runtime_order_keeps_first_non_empty_non_global_name() {
let config: Value = serde_yaml_ng::from_str(
r#"
proxy-groups:
- name: Beta
- name: ""
- name: GLOBAL
- name: " Alpha "
- name: Beta
"#,
)
.expect("parse runtime");
assert_eq!(
runtime_group_order(config.as_mapping()),
["Beta".to_owned(), " Alpha ".to_owned()]
);
}
}
+1 -1
View File
@@ -3,11 +3,11 @@ use crate::{
cmd::StringifyErr as _,
cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
config::{Config, IProfiles, PrfItem},
core::auto_backup::{AutoBackupManager, AutoBackupTrigger},
core::{
CoreManager, handle,
validate::{CoreConfigValidator, ValidationOutcome},
},
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
utils::dirs,
};
use clash_verge_logging::{Type, logging, logging_error};
+158 -19
View File
@@ -8,26 +8,81 @@ use crate::{
},
};
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome {
Installed,
#[cfg(any(windows, test))]
Sidecar {
reason: String,
},
}
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
let result = {
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER.handle_service_status(status).await
};
#[cfg(windows)]
return finish_service_installation(result, error_code, |reason| async move {
manager.continue_with_sidecar(Some(&reason)).await
})
.await;
#[cfg(not(windows))]
result
.map(|()| ServiceInstallOutcome::Installed)
.map_err(|error| proxy_aware_coded_error(&error, error_code))
}
#[cfg(any(windows, test))]
async fn finish_service_installation<Fallback, FallbackFuture>(
installation: anyhow::Result<()>,
error_code: &str,
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce(String) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
let Err(error) = installation else {
return Ok(ServiceInstallOutcome::Installed);
};
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
else {
return Err(proxy_aware_coded_error(&error, error_code));
};
let reasons = failure
.status
.cores
.iter()
.filter_map(|core| match &core.availability {
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
_ => None,
})
.collect::<Vec<_>>();
if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code));
}
let reason = reasons.join("\n");
fallback(reason.clone())
.await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
Ok(ServiceInstallOutcome::Sidecar { reason })
}
#[tauri::command]
pub async fn install_service() -> CmdResult {
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
@@ -40,36 +95,120 @@ pub async fn uninstall_service() -> CmdResult {
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult {
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult {
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
#[tauri::command]
pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar()
.continue_with_sidecar(None)
.await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
#[tauri::command]
pub fn take_service_fallback_notice() -> bool {
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
crate::core::notification::sidecar_failure_snapshot()
}
#[tauri::command]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{ServiceInstallOutcome, finish_service_installation};
use clash_verge_service_ipc::{
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
};
use std::cell::Cell;
#[tokio::test]
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
let reason = "core path C:\\ is writable by Everyone";
for (availability, fallback_ready) in [
(None, false),
(Some(CoreAvailability::Missing), false),
(Some(CoreAvailability::DigestMismatch), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
] {
let fallback_called = Cell::new(false);
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus {
service_sha256: String::new(),
protocol: ProtocolInfo::current(),
cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(),
availability,
}],
core_busy: false,
},
}),
None => anyhow::anyhow!("{reason}"),
}
.context("install service failed");
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
fallback_called.set(true);
anyhow::ensure!(fallback_ready, "another core is running");
Ok(())
})
.await;
assert_eq!(fallback_called.get(), rejected);
if rejected && fallback_ready {
assert_eq!(
result,
Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}")
})
);
} else {
let error = result.expect_err("an unavailable fallback must remain an error");
assert_eq!(
error.code.as_deref(),
Some(if rejected {
"SERVICE_SIDECAR_FAILED"
} else {
"SERVICE_INSTALL_FAILED"
})
);
if rejected {
assert!(error.detail.contains("another core is running"));
}
}
}
}
}
+17 -16
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use crate::core::{
handle,
validate::{ValidationErrorKind, ValidationOutcome},
@@ -11,27 +12,27 @@ pub enum ValidationNoticeTarget {
Script,
}
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str {
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
match kind {
ValidationErrorKind::FileMissing => "config_validate::file_not_found",
ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
ValidationErrorKind::FileRead => match target {
ValidationNoticeTarget::Script => "config_validate::script_error",
_ => "config_validate::yaml_read_error",
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
_ => NoticeStatus::ConfigValidateYamlReadError,
},
ValidationErrorKind::YamlSyntax => match target {
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error",
ValidationNoticeTarget::Script => "config_validate::script_error",
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error",
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
},
ValidationErrorKind::YamlMapping => match target {
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error",
ValidationNoticeTarget::Script => "config_validate::script_error",
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error",
ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
},
ValidationErrorKind::ScriptSyntax => "config_validate::script_syntax_error",
ValidationErrorKind::ScriptMissingMain => "config_validate::script_missing_main",
ValidationErrorKind::ProcessTerminated => "config_validate::process_terminated",
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => "config_validate::error",
ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
}
}
@@ -40,12 +41,12 @@ pub fn handle_validation_notice(outcome: &ValidationOutcome, target: ValidationN
ValidationOutcome::Invalid { kind, message } => {
let status = notice_key(*kind, target);
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message);
handle::Handle::notice_message(status, message.to_owned());
handle::Handle::notice(status, message.as_str());
}
ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
let message = outcome.to_string();
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message);
handle::Handle::notice_message("config_validate::error", message);
handle::Handle::notice(NoticeStatus::ConfigValidateError, message.as_str());
}
ValidationOutcome::Valid => {}
}
+17 -10
View File
@@ -1,4 +1,5 @@
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
constants::{files, timing},
@@ -69,6 +70,12 @@ impl Config {
CONFIG_WRITE_LOCK.lock().await
}
pub(crate) fn try_lock_config_write() -> Result<MutexGuard<'static, ()>> {
CONFIG_WRITE_LOCK
.try_lock()
.map_err(|_| anyhow!("configuration update is already running"))
}
pub async fn init_config_before_window() -> Result<()> {
Self::ensure_default_profile_items().await?;
@@ -106,7 +113,7 @@ impl Config {
if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await;
handle::Handle::notice_message(msg_type, msg_content);
handle::Handle::notice(msg_type, msg_content.as_str());
}
Self::runtime().await.apply();
@@ -137,14 +144,14 @@ impl Config {
Ok(())
}
async fn generate_and_validate() -> Result<Option<(&'static str, String)>> {
async fn generate_and_validate() -> Result<Option<(NoticeStatus, String)>> {
if let Err(err) = Self::generate().await {
let error_msg: String = err.to_string().into();
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg)
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.await?;
return Ok(Some(("config_validate::boot_error", error_msg)));
return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
}
logging!(debug, Type::Config, "生成运行时配置成功");
@@ -171,25 +178,25 @@ impl Config {
error_msg
);
CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg)
.use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.await?;
Ok(Some(("config_validate::boot_error", error_msg)))
Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
}
Err(err) => {
logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
CoreManager::global()
.use_default_config("config_validate::process_terminated", "")
.use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
.await?;
Ok(Some(("config_validate::process_terminated", String::new())))
Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, String::new())))
}
}
} else {
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
CoreManager::global()
.use_default_config("config_validate::error", "")
.use_default_config(NoticeStatus::ConfigValidateError, "")
.await?;
Ok(Some(("config_validate::error", String::new())))
Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
}
}
+20 -12
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize};
@@ -108,8 +109,7 @@ impl DnsOverrideState {
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileDnsSettings {
pub enabled: bool,
// Force-enable confirmation is valid only for the current app session.
#[serde(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirmation: Option<String>,
}
@@ -139,12 +139,13 @@ impl Config {
}
// The runtime is already committed; a disk failure cannot undo its effective settings.
transaction.commit();
Handle::refresh_verge();
if state.requested && !state.enabled {
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("dns_override::auto_disabled", "");
Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, "");
}
verge.data_arc().save_file().await
let result = verge.data_arc().save_file().await;
Handle::refresh_verge();
result
}
}
@@ -195,7 +196,7 @@ mod tests {
}
#[test]
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
let source = Some("provider-dns".into());
let confirmed = IVerge {
profile_dns_settings: [(
@@ -208,15 +209,22 @@ mod tests {
.into(),
..IVerge::default()
};
let settings = confirmed.dns_settings_for("one");
assert!(DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation).enabled);
for saved in [
serde_yaml_ng::to_string(&confirmed)?,
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
let saved = serde_yaml_ng::to_string(&confirmed)?;
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut restarted));
for (saved, source) in [
(saved, Some("updated".into())),
(
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
source,
),
] {
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
assert!(!state.enabled, "a previous session must not bypass startup protection");
assert!(state.apply_to(&mut restarted));
assert!(!restarted.dns_settings_for("one").enabled);
+8 -19
View File
@@ -1,4 +1,5 @@
use super::{Config, IClashTemp, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{
constants::timing,
core::{
@@ -14,15 +15,9 @@ use crate::{
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_yaml_ng::Value;
use std::{
collections::HashSet,
net::SocketAddr,
str::FromStr as _,
sync::atomic::{AtomicBool, Ordering},
};
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
#[derive(Clone, Copy)]
struct MixedPortFallback {
@@ -30,9 +25,8 @@ struct MixedPortFallback {
current: u16,
}
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
@@ -147,17 +141,12 @@ impl Config {
}
pub(crate) fn block_startup_core(error: &anyhow::Error) {
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string());
}
pub(crate) fn startup_core_block_reason() -> Option<String> {
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
STARTUP_CORE_BLOCK_REASON.lock().clone()
} else {
None
}
STARTUP_CORE_BLOCK_REASON.lock().clone()
}
pub(crate) fn notify_startup_mixed_port_fallback() {
@@ -166,8 +155,8 @@ impl Config {
};
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message(
"mixed_port::fallback",
Handle::notice(
NoticeStatus::MixedPortFallback,
format!("{},{}", change.original, change.current),
);
});
@@ -196,7 +185,7 @@ fn report_fallback_error(message: String) {
);
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message("mixed_port::fallback_error", message);
Handle::notice(NoticeStatus::MixedPortFallbackError, message);
});
}
+5
View File
@@ -48,6 +48,9 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>,
@@ -217,6 +220,7 @@ impl PrfItem {
desc: Some(desc),
file: Some(file),
url: None,
latency_test_urls: None,
selected: None,
extra: None,
option: Some(PrfOption {
@@ -396,6 +400,7 @@ impl PrfItem {
desc: desc.cloned(),
file: Some(file),
url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None,
extra,
option: Some(PrfOption {
+156 -148
View File
@@ -2,6 +2,8 @@ use super::{
PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
};
use crate::core::notify::{Refresh, announce};
use crate::utils::retry::{RetryError, RetryPolicy, retry, retry_with_state};
use crate::{
core::{handle, tray::Tray},
utils::{
@@ -16,6 +18,7 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::num::NonZeroUsize;
use std::{
collections::{HashMap, HashSet},
path::{Component, Path},
@@ -245,6 +248,7 @@ impl IProfiles {
patch!(each, item, file);
patch!(each, item, url);
patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra);
patch!(each, item, updated);
patch!(each, item, option);
@@ -600,19 +604,21 @@ fn is_activation_current(generation: u64) -> bool {
}
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
loop {
match handle::Handle::mihomo().get_proxies().await {
Ok(proxies) => return proxies,
Err(err) => {
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err:#}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
})
let interval = Duration::from_millis(500);
let attempts =
NonZeroUsize::MIN.saturating_add((MIHOMO_OPERATION_TIMEOUT.as_millis() / interval.as_millis()) as usize + 1);
tokio::time::timeout(
MIHOMO_OPERATION_TIMEOUT,
retry(RetryPolicy::fixed(attempts, interval), |_| async {
handle::Handle::mihomo().get_proxies().await.map_err(|error| {
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {error:#}");
RetryError::Retry(error)
})
}),
)
.await
.context("timed out while waiting for mihomo proxies")
.context("timed out while waiting for mihomo proxies")?
.map_err(Into::into)
}
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
@@ -691,51 +697,58 @@ async fn update_tray_after_activation(generation: u64) {
}
}
async fn persist_selected_update<F, Fut>(supersede: bool, edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<(IProfiles, bool)>> + Send,
{
let changed = Config::profiles().await.with_data_modify(edit).await?;
if changed {
if supersede {
supersede_selected_activation();
}
announce(Refresh::Profiles);
}
Ok(())
}
/// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
let recorded = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
}
entry.now = Some(node);
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
}
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
entry.now = Some(node);
}
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if recorded {
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
}
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
}
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
@@ -745,36 +758,28 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
}
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
let cleared = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
if !remove_selected_node(&mut selected, group_name) {
return Ok((profiles, false));
}
let mut selected = item.selected.clone().unwrap_or_default();
if !remove_selected_node(&mut selected, group_name) {
return Ok((profiles, false));
}
item.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if cleared {
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
item.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
}
async fn persist_reconciled_selected(
@@ -787,34 +792,27 @@ async fn persist_reconciled_selected(
return Ok(());
}
let profiles = Config::profiles().await;
let profile_uid = profile_uid.clone();
let original_selected = original_selected.to_vec();
let updated = profiles
.with_data_modify(move |mut profiles| async move {
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
return Ok((profiles, false));
}
persist_selected_update(false, move |mut profiles| async move {
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
return Ok((profiles, false));
}
let profile = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
return Ok((profiles, false));
}
let profile = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid)))
.with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
return Ok((profiles, false));
}
profile.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if updated {
handle::Handle::refresh_profiles();
}
Ok(())
profile.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await
}
/// Returns recorded selections whose groups have not reached the requested node.
@@ -836,48 +834,58 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
/// Retries selections while provider-backed groups finish loading.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
loop {
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
if !is_activation_current(generation) {
return;
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
// Unreachable core: the deadline still applies, so this cannot spin forever.
if Instant::now() >= deadline {
return;
}
continue;
};
if !is_activation_current(generation) {
return;
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return;
}
if Instant::now() >= deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return;
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, generation)
.await
.is_none()
{
return;
}
if is_activation_current(generation) {
handle::Handle::refresh_clash();
}
}
let attempts = NonZeroUsize::MIN.saturating_add(
(SELECTED_NODES_SETTLE_DEADLINE.as_millis() / SELECTED_NODES_SETTLE_INTERVAL.as_millis()) as usize,
);
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
let _ = retry_with_state(
RetryPolicy::fixed(attempts, SELECTED_NODES_SETTLE_INTERVAL),
(selected, completed, generation, deadline),
|state, _| {
Box::pin(async move {
let (selected, completed, generation, deadline) = state;
if !is_activation_current(*generation) {
return Ok(());
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
return if Instant::now() >= *deadline {
Ok(())
} else {
Err(RetryError::Retry(()))
};
};
if !is_activation_current(*generation) {
return Ok(());
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return Ok(());
}
if Instant::now() >= *deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return Ok(());
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, *generation)
.await
.is_none()
{
return Ok(());
}
if is_activation_current(*generation) {
announce(Refresh::Clash);
}
Err(RetryError::Retry(()))
})
},
)
.await;
}
/// Releases the first-pass waiter even when restoration exits early.
@@ -920,7 +928,7 @@ async fn activate_selected_nodes_worker(
}
if is_activation_current(generation) {
handle::Handle::refresh_clash();
announce(Refresh::Clash);
}
let plan = if needs_confirmation {
@@ -944,7 +952,7 @@ async fn activate_selected_nodes_worker(
return Ok(());
};
if confirmed_activated_count > 0 && is_activation_current(generation) {
handle::Handle::refresh_clash();
announce(Refresh::Clash);
}
confirmed_plan
} else {
@@ -1034,7 +1042,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if selected.is_empty() {
if is_activation_current(generation) {
handle::Handle::refresh_clash();
announce(Refresh::Clash);
}
return Ok(());
}
@@ -1046,7 +1054,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if let Err(err) = result {
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
// The profile itself is already active even if node restoration failed.
handle::Handle::refresh_clash();
announce(Refresh::Clash);
}
update_tray_after_activation(generation).await;
logging!(debug, Type::Config, "activating selected nodes done!");
+1 -1
View File
@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail};
use std::{io::ErrorKind, path::PathBuf};
pub(crate) struct FileSnapshot {
path: PathBuf,
pub(crate) path: PathBuf,
content: Option<Vec<u8>>,
}
+1 -1
View File
@@ -61,7 +61,7 @@ pub mod files {
}
pub mod tun {
pub const DEFAULT_STACK: &str = "gvisor";
pub const DEFAULT_STACK: &str = "mips";
pub const DNS_HIJACK: &[&str] = &["any:53"];
@@ -7,12 +7,11 @@ use crate::{
use anyhow::Result;
use chrono::Local;
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use std::{
path::PathBuf,
sync::{
Arc,
Arc, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering},
},
time::{Duration, UNIX_EPOCH},
@@ -92,7 +91,7 @@ pub struct AutoBackupManager {
impl AutoBackupManager {
pub fn global() -> &'static Self {
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new();
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| {
let (tx, _rx) = watch::channel(AutoBackupSettings::default());
Self {
+2 -3
View File
@@ -4,7 +4,6 @@ use anyhow::Error;
use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
use smartstring::alias::String;
use std::{
@@ -12,7 +11,7 @@ use std::{
env::{consts::OS, temp_dir},
io::Write as _,
path::PathBuf,
sync::Arc,
sync::{Arc, OnceLock},
time::Duration,
};
use tokio::{fs, time::timeout};
@@ -58,7 +57,7 @@ pub struct WebDavClient {
impl WebDavClient {
pub fn global() -> &'static Self {
static WEBDAV_CLIENT: OnceCell<WebDavClient> = OnceCell::new();
static WEBDAV_CLIENT: OnceLock<WebDavClient> = OnceLock::new();
WEBDAV_CLIENT.get_or_init(|| Self {
config: ArcSwapOption::new(None),
clients: ArcSwap::new(Arc::new(HashMap::new())),
+4 -3
View File
@@ -1,8 +1,9 @@
use crate::core::notify::NoticeStatus;
use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::notification::{NotificationEvent, notify_event};
use crate::utils::window_manager::WindowManager;
use crate::{config::Config, core::handle, feat, module::lightweight::entry_lightweight_mode};
use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_mode, feat};
use anyhow::{Result, bail};
use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging};
@@ -177,7 +178,7 @@ impl Hotkey {
"Hotkey profile reactivation failed validation: {}",
message.as_str()
);
handle::Handle::notice_message("reactivate_profiles::error", message);
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
}
Err(err) => {
logging!(
@@ -186,7 +187,7 @@ impl Hotkey {
"Failed to reactivate subscriptions via hotkey: {}",
err
);
handle::Handle::notice_message("reactivate_profiles::error", err.to_string());
handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
}
});
}
+2 -9
View File
@@ -271,10 +271,7 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
}
let normalized = bind_address
.strip_prefix('[')
.and_then(|address| address.strip_suffix(']'))
.unwrap_or(bind_address);
let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
Ok(vec![
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
])
@@ -298,11 +295,7 @@ fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
if port == 0 {
bail!("listener port must be between 1 and 65535");
}
let host = host
.trim()
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or_else(|| host.trim());
let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
if host.eq_ignore_ascii_case("localhost") {
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
}
+99 -5
View File
@@ -1,4 +1,5 @@
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::core::notify::NoticeStatus;
use crate::core::service::StageRequest;
use crate::{
config::{Config, IProfiles, runtime::IRuntime},
@@ -18,6 +19,7 @@ use smartstring::alias::String;
use std::{
collections::HashSet,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
use tauri_plugin_mihomo::Error as MihomoError;
@@ -129,7 +131,31 @@ where
}
impl CoreManager {
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
pub(crate) fn claim_config_update(
&self,
_config_write: &tokio::sync::MutexGuard<'_, ()>,
) -> Result<ConfigUpdateGuard<'_>> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
}
Ok(ConfigUpdateGuard(self))
}
pub(crate) async fn update_config_in_patch(&self, _update: &ConfigUpdateGuard<'_>) -> Result<()> {
if handle::Handle::global().is_exiting() {
anyhow::bail!("application is exiting");
}
self.set_last_update(Instant::now());
Config::generate().await?;
let outcome = self.validate_and_apply_draft().await?;
if outcome.is_valid() {
Ok(())
} else {
Err(anyhow!("{outcome}"))
}
}
pub async fn use_default_config(&self, status: NoticeStatus, message: &str) -> Result<()> {
use crate::constants::files::RUNTIME_CONFIG;
let runtime_path = dirs::app_home_dir()?.join(RUNTIME_CONFIG);
@@ -145,7 +171,7 @@ impl CoreManager {
});
help::save_yaml(&runtime_path, &clash_config, Some("# Clash Verge Runtime")).await?;
handle::Handle::notice_message(error_key, error_msg);
handle::Handle::notice(status, message);
Ok(())
}
@@ -234,6 +260,7 @@ impl CoreManager {
let outcome = self.perform_config_update(Some(profiles)).await?;
if outcome.is_valid() {
crate::config::profiles::restore_selected_nodes().await;
handle::Handle::refresh_clash();
Ok(())
} else {
Err(anyhow!("failed to restore previous Core configuration: {outcome}"))
@@ -297,8 +324,27 @@ impl CoreManager {
self.validate_and_apply(transaction).await
}
/// Validates and applies the caller's transaction, committing only on success.
/// Commits the applied runtime even if restoring the system proxy subsequently fails.
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
let runtime = Config::runtime().await;
let original_runtime = runtime.data_arc();
let outcome = match self.validate_and_apply_draft().await {
Ok(outcome) => outcome,
Err(error) => {
transaction.rollback();
if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) {
handle::Handle::refresh_clash();
}
return Err(error);
}
};
if outcome.is_valid() {
transaction.commit();
}
Ok(outcome)
}
async fn validate_and_apply_draft(&self) -> Result<ValidationOutcome> {
// One serialization feeds check and run files; the core never applies unvalidated bytes.
let yaml = Config::runtime_config_yaml().await?;
let outcome = CoreConfigValidator::global()
@@ -309,11 +355,34 @@ impl CoreManager {
}
let run_path = Config::write_runtime_file(&yaml).await?;
self.apply_config(run_path).await?;
transaction.commit();
let previous = self.current_core_readiness_generation();
let result = self.apply_config(run_path).await;
self.retain_applied_runtime(&Config::runtime().await, previous, &result);
result?;
// Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it.
#[cfg(target_os = "macos")]
{
let _lifecycle = self.lifecycle_lock.lock().await;
crate::utils::resolve::dns::sync_public_dns().await;
}
Ok(ValidationOutcome::Valid)
}
fn retain_applied_runtime(
&self,
runtime: &clash_verge_draft::Draft<IRuntime>,
previous: Option<u64>,
result: &Result<()>,
) {
if result.is_ok()
|| self
.current_core_readiness_generation()
.is_some_and(|current| Some(current) != previous)
{
runtime.apply();
}
}
/// Applies a generated configuration through the active core owner.
async fn apply_config(&self, path: PathBuf) -> Result<()> {
if matches!(*self.get_running_mode(), RunningMode::Service) {
@@ -405,6 +474,31 @@ mod tests {
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
use std::{cell::Cell, time::Duration};
#[test]
fn a_ready_replacement_keeps_the_applied_runtime_when_proxy_restore_fails() -> anyhow::Result<()> {
use crate::{config::runtime::IRuntime, core::CoreManager};
use clash_verge_draft::{Draft, DraftTransaction};
for replaced in [false, true] {
let manager = CoreManager::default();
manager.mark_core_ready();
let previous = manager.current_core_readiness_generation();
let runtime = Draft::new(IRuntime::default());
let transaction = DraftTransaction::begin(vec![&runtime])?;
runtime.edit_draft(|draft| {
draft.exists_keys.insert("replacement".into());
});
if replaced {
manager.mark_core_ready();
}
let result = Err(anyhow::anyhow!("system proxy restore failed"));
manager.retain_applied_runtime(&runtime, previous, &result);
transaction.rollback();
assert_eq!(runtime.data_arc().exists_keys.contains("replacement"), replaced);
}
Ok(())
}
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
fn staged() -> StageAttempt {
+137 -68
View File
@@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_)
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
| ServiceStatus::SidecarAllowed
) | (
RunningMode::Sidecar,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
)
)
}
@@ -330,6 +334,18 @@ enum HandoffOutcome {
Failed,
}
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
let error = match service_rejection {
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
None => error,
};
// Proxy failures have their own pending state and recovery dialog.
if SysproxyFailure::from_chain(&error).is_none() {
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
}
error
}
impl CoreManager {
async fn rollback_failed_start(&self) {
proxy_control::stop_guard().await;
@@ -360,9 +376,12 @@ impl CoreManager {
}
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
pub async fn continue_with_sidecar(&self) -> Result<()> {
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
if !self.try_start_config_update() {
anyhow::bail!("configuration update is already running");
return Err(report_sidecar_failure(
anyhow::anyhow!("configuration update is already running"),
service_rejection,
));
}
defer! {
self.finish_config_update();
@@ -371,59 +390,68 @@ impl CoreManager {
// draft without claiming it, so without this it can commit another transaction's staged patch.
let config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await;
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available().await?;
}
SERVICE_MANAGER.allow_sidecar_for_session()?;
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
Config::generate().await
}
.await;
drop(config_write);
if let Err(error) = prepared {
SERVICE_MANAGER.withdraw_sidecar_allowance();
return Err(error);
}
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
proxy_control::stop_guard().await;
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
let result = async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready");
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)?;
}
if !matches!(status, ServiceStatus::SidecarAllowed) {
SERVICE_MANAGER.allow_sidecar_for_session()?;
}
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
if crate::core::runstate::RUN_STATE
.state()
.tun_should_be_disabled(tun_enabled)
{
tracing::Span::current().record("tun_disabled", true);
Config::disable_tun_and_persist().await?;
}
Config::generate().await
}
.await;
drop(config_write);
if let Err(error) = prepared {
SERVICE_MANAGER.withdraw_sidecar_allowance();
return Err(error);
}
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
proxy_control::stop_guard().await;
let result = async {
self.start_core_inner().await?;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|| self.current_core_readiness_generation().is_none()
{
anyhow::bail!("Sidecar did not become ready");
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await
}
.await;
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
}
.await;
if let Err(error) = result {
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
SERVICE_MANAGER.withdraw_sidecar_allowance();
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
return Err(proxy_control::rollback_failure(error, cleanup_error)
.context("failed to clear the proxy before rolling back"));
}
return Err(error);
}
Ok(())
result.map_err(|error| report_sidecar_failure(error, service_rejection))
}
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
@@ -489,7 +517,13 @@ impl CoreManager {
}
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
let result =
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await;
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
@@ -518,6 +552,8 @@ impl CoreManager {
.ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
})?;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
// At login the app usually beats the network. Leave the write to the network watcher
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
@@ -566,6 +602,7 @@ impl CoreManager {
}
// tell the window when a background apply lands
Handle::refresh_verge();
crate::core::notification::retire_sidecar_failure();
Ok(())
}
@@ -588,6 +625,8 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
@@ -603,7 +642,11 @@ impl CoreManager {
{
run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(),
clash_verge_service_ipc::execution::check_sidecar_available,
|| async {
clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)
},
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
@@ -622,6 +665,8 @@ impl CoreManager {
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
return result;
}
@@ -646,7 +691,10 @@ impl CoreManager {
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()),
};
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
.await
.map_err(super::state::explain_core_occupancy)
{
logging!(
warn,
Type::Core,
@@ -672,7 +720,7 @@ impl CoreManager {
SERVICE_MANAGER.withdraw_sidecar_allowance();
self.rollback_failed_start().await;
} else {
crate::core::service::notify_service_fallback();
crate::core::service::notify_service_fallback(&reason);
}
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
}
@@ -710,11 +758,17 @@ impl CoreManager {
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
let result = match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()),
};
// A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS.
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
#[tracing::instrument(skip_all, level = "info")]
@@ -796,7 +850,12 @@ impl CoreManager {
return;
}
if crate::core::runstate::RUN_STATE.state().service_usable() {
let state = crate::core::runstate::RUN_STATE.state();
// Waiting cannot bring up a service nothing will start.
if state.service_usable()
|| matches!(&state.health, crate::core::runstate::ServiceHealth::Unavailable(reason)
if crate::core::service::is_not_auto_started(reason))
{
return;
}
@@ -834,29 +893,34 @@ impl CoreManager {
AsyncHandler::spawn(|| async move {
let manager = Self::global();
let started = Instant::now();
loop {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let attempts = std::num::NonZeroUsize::MIN.saturating_add(
(timing::SERVICE_HANDOFF_WINDOW.as_millis() / timing::SERVICE_HANDOFF_INTERVAL.as_millis()) as usize,
);
let _ = retry(RetryPolicy::fixed(attempts, std::time::Duration::ZERO), |_| async {
if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
logging!(
info,
Type::Core,
"service handoff window elapsed; staying in sidecar mode"
);
break;
return Ok(());
}
// The window is checked before waiting, including the final handoff attempt.
tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
break;
return Ok(());
}
match manager.try_handoff_sidecar_to_service().await {
HandoffOutcome::Done => break,
HandoffOutcome::Done => Ok(()),
HandoffOutcome::Failed => {
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode");
break;
Ok(())
}
HandoffOutcome::NotReady => {}
HandoffOutcome::NotReady => Err(RetryError::Retry(())),
}
}
})
.await;
manager.handoff_watcher_running.store(false, Ordering::Release);
});
}
@@ -1474,12 +1538,18 @@ mod tests {
ServiceStatus::NeedsReinstall,
ServiceStatus::InstallRequired,
ServiceStatus::Unavailable("offline".into()),
ServiceStatus::SidecarAllowed,
];
for status in &allowed_statuses {
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
if !matches!(status, ServiceStatus::InstallRequired) {
if !matches!(
status,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
) {
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
} else {
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
}
}
@@ -1489,7 +1559,6 @@ mod tests {
ServiceStatus::UninstallRequired,
ServiceStatus::ReinstallRequired,
ServiceStatus::ForceReinstallRequired,
ServiceStatus::SidecarAllowed,
];
for status in &rejected_statuses {
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
+97 -40
View File
@@ -1,15 +1,15 @@
mod config;
pub(crate) use config::ConfigUpdateGuard;
mod lifecycle;
mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging};
use once_cell::sync::Lazy;
use std::{
fmt,
sync::{
Arc,
Arc, LazyLock,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Instant,
@@ -21,7 +21,7 @@ use crate::singleton;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
pub(crate) static CLASH_LOGGER: Lazy<Arc<LogRing>> = Lazy::new(|| Arc::new(LogRing::new()));
pub(crate) static CLASH_LOGGER: LazyLock<Arc<LogRing>> = LazyLock::new(|| Arc::new(LogRing::new()));
tokio::task_local! {
static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
@@ -74,6 +74,14 @@ impl fmt::Display for RunningMode {
}
}
/// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure {
StartFailed(String),
ServiceCoreStopped(String),
}
#[derive(Debug)]
pub struct CoreManager {
/// The Run State this manager reports transitions to.
@@ -87,6 +95,7 @@ pub struct CoreManager {
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
@@ -115,6 +124,7 @@ impl Default for CoreManager {
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
core_readiness_state: AtomicU64::new(0),
startup_error: parking_lot::Mutex::new(None),
sidecar_exit: tokio::sync::Mutex::new(None),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
@@ -141,6 +151,17 @@ impl CoreManager {
self.run_state.mode_arc()
}
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
let mut startup_error = self.startup_error.lock();
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
*startup_error = Some(failure);
}
}
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
self.startup_error.lock().clone()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
self.state
.load()
@@ -166,7 +187,9 @@ impl CoreManager {
if previous != mode {
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
}
let mut startup_error = self.startup_error.lock();
self.run_state.core_started(mode);
*startup_error = None;
}
/// The Core is no longer running, for any reason.
@@ -214,14 +237,14 @@ impl CoreManager {
}
}
fn current_core_readiness_generation(&self) -> Option<u64> {
pub(crate) fn current_core_readiness_generation(&self) -> Option<u64> {
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
}
pub(crate) fn invalidate_core_readiness(&self) {
let _ = self
.core_readiness_state
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |current| {
active_core_readiness_generation(current).map(inactive_core_readiness_state)
});
}
@@ -261,49 +284,83 @@ impl CoreManager {
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
}
let mut retries = 0;
loop {
match self.start_core().await {
Ok(()) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning));
}
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(MAX_PORT_FALLBACK_RETRIES),
std::time::Duration::ZERO,
),
|retries| async move {
match self.start_core().await {
Ok(()) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(start_error);
Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning))
}
match crate::config::Config::resolve_startup_mixed_port().await {
Ok(true) => {
retries += 1;
tracing::Span::current().record("retries", retries);
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
retries,
MAX_PORT_FALLBACK_RETRIES
);
}
Ok(false) => {
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(start_error);
return Err(RetryError::Stop(start_error));
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
return Err(start_error.context(format!(
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
)));
match crate::config::Config::resolve_startup_mixed_port().await {
Ok(true) => {
let retries = retries + 1;
tracing::Span::current().record("retries", retries);
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{}): {start_error:#}",
retries,
MAX_PORT_FALLBACK_RETRIES
);
Err(RetryError::Retry(start_error))
}
Ok(false) => {
crate::config::Config::notify_startup_mixed_port_fallback();
Err(RetryError::Stop(start_error))
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
Err(RetryError::Stop(start_error.context(format!(
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}"
))))
}
}
}
Err(error) => {
crate::config::Config::notify_startup_mixed_port_fallback();
Err(RetryError::Stop(error))
}
}
Err(error) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(error);
}
}
}
},
)
.await
}
}
singleton!(CoreManager, CORE_MANAGER);
#[cfg(test)]
mod startup_error_tests {
use super::{CoreFailure, CoreManager, RunningMode};
#[test]
fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() {
for mode in [RunningMode::Sidecar, RunningMode::Service] {
let manager = CoreManager::isolated();
manager.record_startup_error(CoreFailure::StartFailed("startup failed".into()));
manager.core_started(mode);
manager.core_stopped();
assert_eq!(manager.get_startup_error(), None);
}
}
#[test]
fn unresolved_startup_error_survives_repeated_reads() {
let manager = CoreManager::isolated();
let failure = CoreFailure::StartFailed("startup failed".into());
manager.record_startup_error(failure.clone());
for _ in 0..2 {
assert_eq!(manager.get_startup_error().as_ref(), Some(&failure));
}
}
}
+60 -33
View File
@@ -1,6 +1,7 @@
#[cfg(test)]
use super::claim_core_readiness_generation;
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::utils::retry::{RetryError, RetryPolicy, retry};
use crate::{
AsyncHandler,
config::Config,
@@ -11,10 +12,20 @@ use crate::{
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use log::Level;
use std::num::NonZeroUsize;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
let detail = error.to_string();
if detail.starts_with("process verge-mihomo") && detail.ends_with("; refusing a second core") {
error.context(clash_verge_i18n::t!("core.alreadyRunning").into_owned())
} else {
error
}
}
#[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value};
@@ -41,14 +52,15 @@ async fn retry_service_start<Start, StartFuture>(
mut start: Start,
) -> Result<()>
where
Start: FnMut() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Start: FnMut() -> StartFuture + Send,
StartFuture: std::future::Future<Output = Result<()>> + Send,
{
let mut last_error = None;
for attempt in 0..attempts {
match start().await {
Ok(()) => return Ok(()),
Err(error) => {
let attempts = NonZeroUsize::new(attempts).ok_or_else(|| anyhow::anyhow!("service start failed"))?;
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
// The Windows Service IPC future would otherwise inflate every enclosing lifecycle future.
let future = Box::pin(start());
async move {
future.await.map_err(|error| {
logging!(
warn,
Type::Core,
@@ -60,16 +72,14 @@ where
.downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
{
return Err(error);
RetryError::Stop(error)
} else {
RetryError::Retry(error)
}
last_error = Some(error);
if attempt + 1 < attempts {
tokio::time::sleep(retry_delay).await;
}
}
})
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
})
.await
}
impl CoreManager {
@@ -95,14 +105,16 @@ async fn poll_sidecar_readiness<F, Fut>(
mut probe: F,
) -> Result<()>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
F: FnMut() -> Fut + Send,
Fut: std::future::Future<Output = Result<()>> + Send,
{
let mut last_error = None;
for attempt in 0..max_attempts {
match probe().await {
Ok(()) => return Ok(()),
Err(error) => {
let attempts = NonZeroUsize::new(max_attempts)
.context("sidecar readiness was configured with no attempts")
.context("Mihomo API did not become ready")?;
retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
let future = probe();
async move {
future.await.map_err(|error| {
logging!(
debug,
Type::Core,
@@ -110,16 +122,12 @@ where
attempt + 1,
max_attempts
);
last_error = Some(error);
}
RetryError::Retry(error)
})
}
if attempt + 1 < max_attempts {
tokio::time::sleep(retry_delay).await;
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
.context("Mihomo API did not become ready"))
})
.await
.context("Mihomo API did not become ready")
}
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
@@ -153,7 +161,10 @@ impl CoreManager {
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
self.wait_for_sidecar_exit().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
.await
.inspect_err(service::record_residual_service)
.map_err(explain_core_occupancy)?;
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
@@ -246,7 +257,7 @@ impl CoreManager {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
let message = String::from_utf8_lossy_owned(line);
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
@@ -392,6 +403,8 @@ impl CoreManager {
self.set_job_handle(None);
proxy_control::stop_guard().await;
self.core_stopped();
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
}
}
@@ -508,6 +521,20 @@ mod readiness_tests {
time::Duration,
};
#[test]
fn core_occupancy_explanation_preserves_the_original_error() {
for detail in [
"process verge-mihomo remains after IPC failure; refusing a second core",
"process verge-mihomo-alpha.exe (PID 123) is still running; refusing a second core",
] {
let error = super::explain_core_occupancy(anyhow::anyhow!(detail));
assert_eq!(error.to_string(), clash_verge_i18n::t!("core.alreadyRunning"));
assert_eq!(error.root_cause().to_string(), detail);
}
let error = super::explain_core_occupancy(anyhow::anyhow!("could not inspect remaining cores"));
assert_eq!(error.to_string(), "could not inspect remaining cores");
}
#[tokio::test]
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
let attempts = Arc::new(AtomicUsize::new(0));
+4 -2
View File
@@ -1,13 +1,14 @@
pub mod auto_backup;
pub mod autostart;
pub mod backup;
pub mod handle;
pub mod hotkey;
pub mod lightweight;
pub mod listener;
pub mod logger;
pub mod manager;
#[cfg(target_os = "macos")]
pub mod network_watch;
pub mod notification;
pub mod notify;
pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
@@ -22,3 +23,4 @@ pub mod validate;
pub mod win_uwp;
pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater};
pub use notify::{handle, notification};
+92
View File
@@ -0,0 +1,92 @@
use super::{handle::Handle, notification::FrontendEvent};
use parking_lot::Mutex;
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refresh {
Clash,
Verge,
Profiles,
Proxies,
}
const REFRESH_WINDOW: Duration = Duration::from_millis(20);
static BATCH: RefreshBatch = RefreshBatch(Mutex::new(Vec::new()));
#[derive(Default)]
struct RefreshBatch(Mutex<Vec<Refresh>>);
impl RefreshBatch {
fn enqueue(&self, refresh: Refresh) -> bool {
let mut pending = self.0.lock();
let start_window = pending.is_empty();
if !pending.contains(&refresh) {
pending.push(refresh);
}
start_window
}
async fn flush(&self, emit: impl Fn(Refresh)) {
// A fixed window bounds latency even under a continuous stream of refreshes.
tokio::time::sleep(REFRESH_WINDOW).await;
let refreshes = std::mem::take(&mut *self.0.lock());
for refresh in refreshes {
emit(refresh);
}
}
}
pub fn announce(refresh: Refresh) {
if BATCH.enqueue(refresh) {
crate::AsyncHandler::spawn(|| BATCH.flush(emit));
}
}
fn emit(refresh: Refresh) {
Handle::send_event(match refresh {
Refresh::Clash => FrontendEvent::RefreshClash,
Refresh::Verge => FrontendEvent::RefreshVerge,
Refresh::Profiles => FrontendEvent::RefreshProfiles,
Refresh::Proxies => FrontendEvent::RefreshProxyConfig,
});
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> anyhow::Result<()> {
let batch = RefreshBatch::default();
let delivered = Mutex::new(Vec::new());
assert!(batch.enqueue(Refresh::Clash));
for refresh in [Refresh::Verge, Refresh::Clash, Refresh::Profiles, Refresh::Proxies] {
assert!(!batch.enqueue(refresh));
}
tokio::time::timeout(Duration::from_secs(1), async {
batch
.flush(|refresh| {
delivered.lock().push(refresh);
if refresh == Refresh::Clash {
assert!(batch.enqueue(Refresh::Clash));
assert!(!batch.enqueue(Refresh::Clash));
}
})
.await;
batch.flush(|refresh| delivered.lock().push(refresh)).await;
})
.await?;
assert_eq!(
*delivered.lock(),
[
Refresh::Clash,
Refresh::Verge,
Refresh::Profiles,
Refresh::Proxies,
Refresh::Clash
]
);
assert!(batch.enqueue(Refresh::Verge));
Ok(())
}
}
@@ -1,6 +1,10 @@
use super::NoticeStatus;
use crate::{APP_HANDLE, singleton};
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tauri::AppHandle;
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
@@ -36,19 +40,11 @@ impl Handle {
}
pub fn refresh_clash() {
Self::send_event(FrontendEvent::RefreshClash);
super::announce(super::Refresh::Clash);
}
pub fn refresh_verge() {
Self::send_event(FrontendEvent::RefreshVerge);
}
pub fn refresh_profiles() {
Self::send_event(FrontendEvent::RefreshProfiles);
}
pub fn refresh_proxy_config() {
Self::send_event(FrontendEvent::RefreshProxyConfig);
super::announce(super::Refresh::Verge);
}
/// Push a Run State snapshot to the frontend.
@@ -80,13 +76,10 @@ impl Handle {
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
}
pub fn notice_message<S: AsRef<str>, M: Into<String>>(status: S, msg: M) {
let status_str = status.as_ref();
let msg_str = msg.into();
pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
Self::send_event(FrontendEvent::NoticeMessage {
status: status_str,
message: msg_str,
status,
message: message.into(),
});
}
@@ -102,7 +95,7 @@ impl Handle {
self.is_exiting.load(Ordering::Acquire)
}
fn send_event(event: FrontendEvent) {
pub(super) fn send_event(event: FrontendEvent) {
let handle = Self::global();
if handle.is_exiting() {
return;
+174
View File
@@ -0,0 +1,174 @@
mod announce;
pub use announce::{Refresh, announce};
pub mod desktop;
pub mod handle;
pub mod notification;
pub use notification::frontend_wire_contract;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum NoticeStatus {
#[serde(rename = "info")]
Info,
#[serde(rename = "import_sub_url::ok")]
ImportSubUrlOk,
#[serde(rename = "import_sub_url::error")]
ImportSubUrlError,
#[serde(rename = "set_config::error")]
SetConfigError,
#[serde(rename = "core_start::error")]
CoreStartError,
#[serde(rename = "service_core::repair_required")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
ServiceCoreRepairRequired,
#[serde(rename = "service_core::app_data_not_owned")]
ServiceCoreAppDataNotOwned,
#[serde(rename = "service_core::sidecar_fallback")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
ServiceCoreSidecarFallback,
#[serde(rename = "dns_override::auto_disabled")]
DnsOverrideAutoDisabled,
#[serde(rename = "enhance::discarded_keys")]
EnhanceDiscardedKeys,
#[serde(rename = "tun_mode::auto_disabled")]
TunModeAutoDisabled,
#[serde(rename = "tun_mode::auto_disable_failed")]
TunModeAutoDisableFailed,
#[serde(rename = "app_restart::core_stop_failed")]
AppRestartCoreStopFailed,
#[serde(rename = "app_quit::core_stop_failed")]
AppQuitCoreStopFailed,
#[serde(rename = "update_with_clash_proxy")]
UpdateWithClashProxy,
#[serde(rename = "update_failed_even_with_clash")]
UpdateFailedEvenWithClash,
#[serde(rename = "reactivate_profiles::error")]
ReactivateProfilesError,
#[serde(rename = "update_failed")]
UpdateFailed,
#[serde(rename = "config_validate::boot_error")]
ConfigValidateBootError,
#[serde(rename = "config_validate::error")]
ConfigValidateError,
#[serde(rename = "config_validate::process_terminated")]
ConfigValidateProcessTerminated,
#[serde(rename = "config_validate::script_error")]
ConfigValidateScriptError,
#[serde(rename = "config_validate::script_syntax_error")]
ConfigValidateScriptSyntaxError,
#[serde(rename = "config_validate::script_missing_main")]
ConfigValidateScriptMissingMain,
#[serde(rename = "config_validate::file_not_found")]
ConfigValidateFileNotFound,
#[serde(rename = "config_validate::yaml_syntax_error")]
ConfigValidateYamlSyntaxError,
#[serde(rename = "config_validate::yaml_read_error")]
ConfigValidateYamlReadError,
#[serde(rename = "config_validate::yaml_mapping_error")]
ConfigValidateYamlMappingError,
#[serde(rename = "config_validate::merge_syntax_error")]
ConfigValidateMergeSyntaxError,
#[serde(rename = "config_validate::merge_mapping_error")]
ConfigValidateMergeMappingError,
#[serde(rename = "config_core::change_success")]
ConfigCoreChangeSuccess,
#[serde(rename = "config_core::change_error")]
ConfigCoreChangeError,
#[serde(rename = "mixed_port::fallback")]
MixedPortFallback,
#[serde(rename = "mixed_port::fallback_error")]
MixedPortFallbackError,
}
impl NoticeStatus {
/// Every variant in golden order. The wire-contract export and the golden
/// test both derive their variant list from here; expected strings stay
/// hand-written in the test so renames cannot slip through.
pub const ALL: [Self; 34] = [
Self::Info,
Self::ImportSubUrlOk,
Self::ImportSubUrlError,
Self::SetConfigError,
Self::CoreStartError,
Self::ServiceCoreRepairRequired,
Self::ServiceCoreAppDataNotOwned,
Self::ServiceCoreSidecarFallback,
Self::DnsOverrideAutoDisabled,
Self::EnhanceDiscardedKeys,
Self::TunModeAutoDisabled,
Self::TunModeAutoDisableFailed,
Self::AppRestartCoreStopFailed,
Self::AppQuitCoreStopFailed,
Self::UpdateWithClashProxy,
Self::UpdateFailedEvenWithClash,
Self::ReactivateProfilesError,
Self::UpdateFailed,
Self::ConfigValidateBootError,
Self::ConfigValidateError,
Self::ConfigValidateProcessTerminated,
Self::ConfigValidateScriptError,
Self::ConfigValidateScriptSyntaxError,
Self::ConfigValidateScriptMissingMain,
Self::ConfigValidateFileNotFound,
Self::ConfigValidateYamlSyntaxError,
Self::ConfigValidateYamlReadError,
Self::ConfigValidateYamlMappingError,
Self::ConfigValidateMergeSyntaxError,
Self::ConfigValidateMergeMappingError,
Self::ConfigCoreChangeSuccess,
Self::ConfigCoreChangeError,
Self::MixedPortFallback,
Self::MixedPortFallbackError,
];
}
#[cfg(test)]
mod tests {
use super::NoticeStatus;
#[test]
fn notice_status_wire_contract() -> Result<(), serde_json::Error> {
let statuses = NoticeStatus::ALL;
let expected = [
"info",
"import_sub_url::ok",
"import_sub_url::error",
"set_config::error",
"core_start::error",
"service_core::repair_required",
"service_core::app_data_not_owned",
"service_core::sidecar_fallback",
"dns_override::auto_disabled",
"enhance::discarded_keys",
"tun_mode::auto_disabled",
"tun_mode::auto_disable_failed",
"app_restart::core_stop_failed",
"app_quit::core_stop_failed",
"update_with_clash_proxy",
"update_failed_even_with_clash",
"reactivate_profiles::error",
"update_failed",
"config_validate::boot_error",
"config_validate::error",
"config_validate::process_terminated",
"config_validate::script_error",
"config_validate::script_syntax_error",
"config_validate::script_missing_main",
"config_validate::file_not_found",
"config_validate::yaml_syntax_error",
"config_validate::yaml_read_error",
"config_validate::yaml_mapping_error",
"config_validate::merge_syntax_error",
"config_validate::merge_mapping_error",
"config_core::change_success",
"config_core::change_error",
"mixed_port::fallback",
"mixed_port::fallback_error",
];
let actual: Vec<_> = statuses
.into_iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
assert_eq!(actual, expected.map(serde_json::Value::from));
Ok(())
}
}
@@ -1,24 +1,22 @@
use super::NoticeStatus;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_json::json;
use smartstring::alias::String;
use std::{
collections::HashMap,
future::Future,
sync::atomic::{AtomicU64, Ordering},
};
use std::sync::Arc;
use std::sync::LazyLock;
use std::{collections::HashMap, future::Future};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum FrontendEvent<'a> {
RefreshClash,
RefreshVerge,
RefreshProfiles,
RefreshProxyConfig,
NoticeMessage {
status: &'a str,
message: String,
status: NoticeStatus,
message: Arc<str>,
},
ProfileChanged {
current_profile_id: &'a String,
@@ -89,16 +87,23 @@ pub struct PendingFailure {
/// Non-destructive pending state, indexed by stable code.
#[derive(Debug, Default)]
struct FailureTable {
entries: Mutex<HashMap<String, PendingFailure>>,
sequence: AtomicU64,
entries: Mutex<FailureEntries>,
}
#[derive(Debug, Default)]
struct FailureEntries {
map: HashMap<String, PendingFailure>,
sequence: u64,
}
impl FailureTable {
fn record(&self, operation: FailedOperation, code: &str, detail: String) {
// Sequence assignment and replacement must share one ordering lock.
let mut entries = self.entries.lock();
entries.sequence = entries.sequence.wrapping_add(1);
// Preserve an unanswered request over a later restore.
let operation = entries
.map
.get(code)
.filter(|existing| existing.operation.outranks(operation))
.map_or(operation, |existing| existing.operation);
@@ -106,13 +111,13 @@ impl FailureTable {
code: code.into(),
detail,
operation,
sequence: self.sequence.fetch_add(1, Ordering::AcqRel).wrapping_add(1),
sequence: entries.sequence,
};
entries.insert(code.into(), failure);
entries.map.insert(code.into(), failure);
}
fn snapshot(&self) -> Vec<PendingFailure> {
let mut failures: Vec<PendingFailure> = self.entries.lock().values().cloned().collect();
let mut failures: Vec<PendingFailure> = self.entries.lock().map.values().cloned().collect();
failures.sort_by_key(|failure| failure.sequence);
failures
}
@@ -120,17 +125,21 @@ impl FailureTable {
/// Return whether a guard failure was retired.
fn retire_guard(&self) -> bool {
let mut entries = self.entries.lock();
let before = entries.len();
entries.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
before != entries.len()
let before = entries.map.len();
entries
.map
.retain(|_, failure| failure.operation != FailedOperation::SystemProxyGuard);
before != entries.map.len()
}
/// Return whether any proxy failure was retired.
fn retire_system_proxy(&self, asked: FailedOperation) -> bool {
let mut entries = self.entries.lock();
let before = entries.len();
entries.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
before != entries.len()
let before = entries.map.len();
entries
.map
.retain(|_, failure| !failure.operation.retired_by_success_of(asked));
before != entries.map.len()
}
}
@@ -229,7 +238,47 @@ pub fn what_was_asked() -> FailedOperation {
.unwrap_or(FailedOperation::SystemProxyRestore)
}
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::default);
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
#[derive(Debug, Default, Clone, serde::Serialize)]
pub struct SidecarFailureSnapshot {
pub revision: u64,
pub detail: Option<String>,
}
impl SidecarFailureSnapshot {
fn record(&mut self, detail: String) {
self.revision += 1;
self.detail = Some(detail);
}
fn recover(&mut self) -> bool {
if self.detail.take().is_none() {
return false;
}
self.revision += 1;
true
}
}
static SIDECAR_FAILURE: LazyLock<Mutex<SidecarFailureSnapshot>> = LazyLock::new(Mutex::default);
pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot {
SIDECAR_FAILURE.lock().clone()
}
pub fn record_sidecar_failure(detail: String) {
SIDECAR_FAILURE.lock().record(detail);
notify_pending_failures_changed();
}
pub fn retire_sidecar_failure() {
let changed = SIDECAR_FAILURE.lock().recover();
if changed {
notify_pending_failures_changed();
}
}
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
PENDING_FAILURES.record(operation, code, detail.into());
@@ -237,7 +286,7 @@ pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<
}
pub fn has_pending_failure(code: &str) -> bool {
PENDING_FAILURES.entries.lock().contains_key(code)
PENDING_FAILURES.entries.lock().map.contains_key(code)
}
/// Return unresolved failures oldest first without clearing them.
@@ -283,9 +332,10 @@ impl NotificationSystem {
FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))),
FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))),
FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)),
FrontendEvent::NoticeMessage { status, message } => {
("verge://notice-message", serde_json::to_value((status, message)))
}
FrontendEvent::NoticeMessage { status, message } => (
"verge://notice-message",
serde_json::to_value((status, message.as_ref())),
),
FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))),
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
@@ -313,3 +363,106 @@ impl NotificationSystem {
}
}
}
#[cfg(test)]
mod wire_tests {
use super::*;
#[test]
fn frontend_event_wire_contract() -> Result<(), serde_json::Error> {
let uid = String::from("profile");
for (event, name, payload) in super::sample_frontend_events(&uid) {
let (actual_name, actual_payload) = NotificationSystem::serialize_event(event);
assert_eq!(actual_name, name);
assert_eq!(actual_payload?, payload);
}
Ok(())
}
}
/// One sample per `FrontendEvent` variant, paired with its wire name and payload.
/// Shared by the wire golden test and the contract export so the two cannot drift.
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
vec![
(
FrontendEvent::RefreshClash,
"verge://refresh-clash-config",
json!("yes"),
),
(
FrontendEvent::RefreshVerge,
"verge://refresh-verge-config",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
(
FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config",
json!(null),
),
(
FrontendEvent::NoticeMessage {
status: NoticeStatus::Info,
message: Arc::from("ok"),
},
"verge://notice-message",
json!(["info", "ok"]),
),
(
FrontendEvent::TimerUpdated { profile_index: uid },
"verge://timer-updated",
json!("profile"),
),
(
FrontendEvent::RunStateChanged {
state: json!({"running": true}),
},
"verge://run-state-changed",
json!({"running": true}),
),
(
FrontendEvent::PendingFailuresChanged,
"verge://pending-failures-changed",
json!(null),
),
(
FrontendEvent::ProfileChanged {
current_profile_id: uid,
},
"profile-changed",
json!("profile"),
),
(
FrontendEvent::ProfileUpdateStarted { uid },
"profile-update-started",
json!({"uid": "profile"}),
),
(
FrontendEvent::ProfileUpdateCompleted { uid },
"profile-update-completed",
json!({"uid": "profile"}),
),
]
}
/// Machine-readable form of the frontend wire contract: every event name with a
/// serialized sample payload, plus every notice status string. The sample
/// payload's JSON shape (string / null / array / object) is what the frontend
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in
/// linux-only window event, not part of this contract.
pub fn frontend_wire_contract() -> serde_json::Value {
let uid = String::from("sample");
let events: Vec<_> = sample_frontend_events(&uid)
.into_iter()
.map(|(event, _, _)| {
let (name, payload) = NotificationSystem::serialize_event(event);
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) })
})
.collect();
let statuses: Vec<_> = NoticeStatus::ALL
.iter()
// A unit variant with a string rename cannot fail to serialize.
.filter_map(|status| serde_json::to_value(status).ok())
.collect();
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
}
+46 -18
View File
@@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::Fil
}
#[cfg(windows)]
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_private_file(path)
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_lock_file(path)
}
/// Repairs an application data root Windows no longer reports as owned by the current user.
@@ -110,7 +110,7 @@ mod windows_owner {
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK,
GetFileInformationByHandle, GetFileType, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
@@ -215,22 +215,49 @@ mod windows_owner {
Ok(file)
}
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
let sid = current_sid()?;
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
match create_file(path, &descriptor) {
Ok(file) => Ok(file),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
{
let file = open_file(path)?;
validate_private_file(&file, descriptor.owner()?)?;
Ok(file)
}
Err(error) => Err(error),
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let wide = wide_path(path)?;
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | READ_CONTROL | WRITE_DAC,
// Replacing a live lock would let a second instance lock a different file.
FILE_SHARE_READ | FILE_SHARE_WRITE,
&attributes,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error()).context("failed to open current-user lock file");
}
let file = unsafe { std::fs::File::from_raw_handle(handle) };
let mut information = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
return Err(std::io::Error::last_os_error()).context("failed to inspect current-user lock file");
}
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|| unsafe { GetFileType(file.as_raw_handle()) } != FILE_TYPE_DISK
|| information.nNumberOfLinks != 1
{
bail!("current-user lock is not an ordinary, single-link file");
}
let expected_owner = descriptor.owner()?;
if !owner_matches(&file, expected_owner)? {
bail!("current-user lock belongs to a different Windows user");
}
// Lock contents are never trusted; records and tokens retain their separate validation.
descriptor.apply_dacl(file.as_raw_handle())?;
validate_private_file(&file, expected_owner)?;
Ok(file)
}
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
@@ -442,7 +469,8 @@ mod windows_owner {
{
continue;
}
// A DACL that refuses even the owner read settles it just as well.
// Failed inspection does not prove that a live lock belongs to another user.
Err(error) if name == crate::utils::server::INSTANCE_LOCK_FILE => return Err(error),
Err(_) => false,
};
if !ours {
@@ -712,7 +740,7 @@ mod windows_owner {
)
};
if status != 0 {
bail!("failed to restrict owner token DACL: Windows error {status}");
bail!("failed to restrict current-user file DACL: Windows error {status}");
}
Ok(())
}
+25 -16
View File
@@ -522,25 +522,34 @@ pub async fn clear() -> Result<()> {
/// A failed system call is usually a transient RPC hiccup, so give it a few tries.
async fn clear_with_retry() -> Result<()> {
for attempt in 1..CLEAR_ATTEMPTS {
match clear_inner().await {
Err(error)
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add((CLEAR_ATTEMPTS - 1) as usize),
CLEAR_RETRY_DELAY,
),
|attempt| async move {
clear_inner().await.map_err(|error| {
if matches!(
SysproxyFailure::from_chain(&error),
Some(SysproxyFailure::SystemCallFailed)
) =>
{
logging!(
warn,
Type::Core,
"clearing the system proxy failed (attempt {attempt}/{CLEAR_ATTEMPTS}); retrying: {error:#}"
);
tokio::time::sleep(CLEAR_RETRY_DELAY).await;
}
other => return other,
}
}
clear_inner().await
) {
if attempt + 1 < CLEAR_ATTEMPTS as usize {
logging!(
warn,
Type::Core,
"clearing the system proxy failed (attempt {}/{CLEAR_ATTEMPTS}); retrying: {error:#}",
attempt + 1
);
}
RetryError::Retry(error)
} else {
RetryError::Stop(error)
}
})
},
)
.await
}
#[tracing::instrument(skip_all, level = "info", fields(route = tracing::field::Empty))]
+121 -309
View File
@@ -1,16 +1,23 @@
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>,
pub group_scopes: BTreeMap<String, GroupScope>,
pub proxies: Proxies,
pub providers: Option<ProxyProviders>,
}
/// The providers a group draws from (Mihomo order) and its `exclude-type` entries.
pub struct GroupScope {
pub providers: Vec<String>,
pub exclude_types: Vec<String>,
}
pub struct ProxyViewBuilder;
#[derive(Debug, PartialEq, Eq, Serialize)]
@@ -68,6 +75,7 @@ pub struct ProxyGroupView {
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
pub history: Vec<DelayHistory>,
pub extra: HashMap<String, Extra>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
@@ -82,6 +90,7 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType,
pub alive: bool,
pub history: Vec<DelayHistory>,
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -167,15 +176,41 @@ pub struct ProxySubscriptionInfo {
pub expire: i64,
}
/// Proxy name -> `(provider name, record id)` in provider order.
type ProviderCandidates = BTreeMap<String, Vec<(String, String)>>;
struct MemberResolver<'a> {
group_names: BTreeSet<String>,
group_scopes: &'a BTreeMap<String, GroupScope>,
records: &'a BTreeMap<String, ProxyNodeView>,
core_node_ids: &'a BTreeMap<String, String>,
provider_candidates: &'a BTreeMap<String, Vec<String>>,
provider_candidates: &'a ProviderCandidates,
provider_available: bool,
}
impl MemberResolver<'_> {
fn resolve(&self, name: String) -> ProxyMemberRef {
impl<'a> MemberResolver<'a> {
/// Same-name records in the order Mihomo lists them in `group`: `use` order, after `exclude-type`.
fn scoped_candidates(&self, group: &str, name: &str) -> Vec<&'a String> {
let (Some(scope), Some(candidates)) = (self.group_scopes.get(group), self.provider_candidates.get(name)) else {
return Vec::new();
};
scope
.providers
.iter()
.flat_map(|provider| candidates.iter().filter(move |(owner, _)| owner == provider))
.map(|(_, record_id)| record_id)
.filter(|record_id| {
!self.records.get(*record_id).is_some_and(|node| {
scope
.exclude_types
.iter()
.any(|excluded| excluded.eq_ignore_ascii_case(node.proxy_type.as_str()))
})
})
.collect()
}
fn resolve(&self, name: String, scoped: Option<&String>) -> ProxyMemberRef {
if self.group_names.contains(&name) {
ProxyMemberRef::Group { name }
} else if let Some(record_id) = self.core_node_ids.get(&name) {
@@ -189,16 +224,17 @@ impl MemberResolver<'_> {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
}
} else {
match self.provider_candidates.get(&name).map(Vec::as_slice) {
Some([record_id]) => ProxyMemberRef::Node {
let candidates = self.provider_candidates.get(&name).map_or(&[][..], Vec::as_slice);
match (scoped, candidates) {
(Some(record_id), _) | (None, [(_, record_id)]) => ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
},
None => ProxyMemberRef::Unresolved {
(None, []) => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Missing,
},
Some(_) => ProxyMemberRef::Unresolved {
(None, _) => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Ambiguous,
},
@@ -211,6 +247,7 @@ impl ProxyViewBuilder {
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
let ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
} = input;
@@ -219,11 +256,13 @@ impl ProxyViewBuilder {
} else {
ProxyViewProviderState::Unavailable
};
let (mut core_groups, core_nodes) = partition_core(proxies);
let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver {
group_names: core_groups.keys().cloned().collect(),
group_scopes: &group_scopes,
records: &records,
core_node_ids: &core_node_ids,
provider_candidates: &provider_candidates,
provider_available: provider_state == ProxyViewProviderState::Ready,
@@ -250,14 +289,14 @@ impl ProxyViewBuilder {
}
}
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
if proxy.all.is_some() {
groups.insert(name, proxy);
} else {
} else if !providers_available || proxy.provider_name.is_empty() {
nodes.insert(name, proxy);
}
}
@@ -291,7 +330,7 @@ fn build_core_records(
fn build_provider_records(
providers: Option<ProxyProviders>,
records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
let providers = providers
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
.unwrap_or_default();
@@ -322,7 +361,7 @@ fn build_provider_records(
candidates
.entry(proxy_name.clone())
.or_insert_with(Vec::new)
.push(record_id.clone());
.push((provider_name.clone(), record_id.clone()));
records.insert(
record_id.clone(),
build_node(
@@ -365,6 +404,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
test_url,
alive,
history,
extra,
udp,
xudp,
tfo,
@@ -373,6 +413,20 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
proxy_type,
..
} = proxy;
// The n-th occurrence of a name in `all` is the n-th scoped candidate.
let mut seen = BTreeMap::<String, (usize, Vec<&String>)>::new();
let members = all
.unwrap_or_default()
.into_iter()
.map(|member| {
let (occurrence, scoped) = seen
.entry(member.clone())
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
let picked = scoped.get(*occurrence).or_else(|| scoped.first()).copied();
*occurrence += 1;
resolver.resolve(member, picked)
})
.collect();
ProxyGroupView {
name,
@@ -384,6 +438,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon,
test_url,
history,
extra,
capabilities: ProxyCapabilities {
udp,
xudp,
@@ -391,11 +446,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
mptcp,
smux,
},
members: all
.unwrap_or_default()
.into_iter()
.map(|name| resolver.resolve(name))
.collect(),
members,
}
}
@@ -407,6 +458,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
test_url,
alive,
history,
extra,
udp,
xudp,
tfo,
@@ -422,6 +474,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
proxy_type,
alive,
history,
extra,
id,
hidden,
icon,
@@ -477,301 +530,60 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use super::*;
use serde_json::json;
use tauri_plugin_mihomo::models::{
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
};
use super::{
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
ProxyViewOrderSource, ProxyViewProviderState,
};
fn node(name: &str) -> Proxy {
Proxy {
name: name.to_owned(),
proxy_type: ProxyType::Shadowsocks,
alive: true,
udp: true,
..Proxy::default()
#[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(())
}
fn group(name: &str, members: &[&str]) -> Proxy {
Proxy {
name: format!("ignored-{name}"),
proxy_type: ProxyType::Selector,
alive: true,
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
..Proxy::default()
}
}
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
let entries = [
("Zulu", group("Zulu", &["node-b", "node-b"])),
("Alpha", group("Alpha", &["node-a"])),
("node-b", node("wrong-node-b")),
("node-a", node("wrong-node-a")),
("A-before-direct", node("wrong-before-direct")),
("DIRECT", node("wrong-direct")),
("REJECT", node("wrong-reject")),
];
let mut proxies = HashMap::new();
if reverse_insert {
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
} else {
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
}
ProxyViewInput {
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
proxies: Proxies { proxies },
#[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,
}
}
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
let providers = entries
.into_iter()
.map(|(provider_key, vehicle_type, names)| {
let provider = ProxyProvider {
name: format!("ignored-{provider_key}"),
vehicle_type,
proxies: names.iter().map(|name| node(name)).collect(),
..ProxyProvider::default()
};
(provider_key.to_owned(), provider)
})
.collect();
ProxyProviders { providers }
}
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers_fixture(vec![
("a", VehicleType::HTTP, &["duplicate"]),
("b", VehicleType::File, &["duplicate"]),
])),
}
}
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers,
}
}
fn input_with_provider_duplicates() -> ProxyViewInput {
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![
("z-provider", VehicleType::HTTP, &["z-node"]),
("unsupported", VehicleType::Compatible, &["ignored"]),
("a-provider", VehicleType::File, &["same", "same"]),
])),
}
}
fn serde_input_with_provider() -> ProxyViewInput {
let provider = ProxyProvider {
name: "ignored-object-name".to_owned(),
vehicle_type: VehicleType::HTTP,
updated_at: None,
subscription_info: Some(SubScriptionInfo {
upload: 1,
download: 2,
total: 3,
expire: 4,
}),
..ProxyProvider::default()
};
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(ProxyProviders {
providers: HashMap::from([("provider-key".to_owned(), provider)]),
}),
}
}
#[test]
fn ordering_and_core_records_are_deterministic() {
let first = ProxyViewBuilder::build(ordered_input(false));
let second = ProxyViewBuilder::build(ordered_input(true));
assert_eq!(first, second);
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
assert_eq!(
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
assert_eq!(first.direct.as_deref(), Some("c:1"));
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
assert_eq!(first.records["c:0"].name, "A-before-direct");
assert_eq!(first.records["c:1"].name, "DIRECT");
assert_eq!(first.records["c:4"].name, "node-b");
assert_eq!(first.groups[0].members.len(), 2);
assert!(matches!(
&first.groups[0].members[0],
ProxyMemberRef::Node { name, .. } if name == "node-b"
));
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
}
#[test]
fn fallback_and_unmatched_groups_are_stable() {
let mut input = ordered_input(false);
input.runtime_group_order = vec![];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Missing".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Zulu".into(), "Zulu".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
}
#[test]
fn global_and_member_resolution_follow_the_required_priority() {
let mut proxies = HashMap::new();
proxies.insert(
"GLOBAL".into(),
group("wrong", &["Nested", "core", "provider-only", "missing"]),
);
proxies.insert("Nested".into(), group("wrong", &[]));
proxies.insert("core".into(), node("wrong"));
let providers = providers_fixture(vec![
(
"a",
VehicleType::HTTP,
&["Nested", "core", "provider-only", "duplicate"],
),
("b", VehicleType::File, &["duplicate"]),
]);
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers),
});
let members = &view.global.as_ref().expect("GLOBAL").members;
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
assert!(matches!(
members[3],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Missing,
..
}
));
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
}
#[test]
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
assert!(matches!(
ready.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Ambiguous,
..
}
));
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
assert!(matches!(
unavailable.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
..
}
));
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
assert!(unavailable.providers.is_empty());
}
#[test]
fn providers_preserve_identity_duplicates_and_member_order() {
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
assert_eq!(
view.providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["a-provider", "z-provider"]
);
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
assert_eq!(view.records["p:0:0"].name, "same");
assert_eq!(view.records["p:0:1"].name, "same");
assert_eq!(
view.records["p:0:0"].source,
ProxyNodeSource::Provider {
provider_name: "a-provider".into(),
proxy_name: "same".into(),
}
);
}
#[test]
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![(
"unsupported",
VehicleType::Compatible,
&["ignored"],
)])),
});
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
assert!(view.providers.is_empty());
}
#[test]
fn serde_matches_the_v1_wire_contract() {
let view = ProxyViewBuilder::build(serde_input_with_provider());
let json = serde_json::to_value(view).expect("serialize view");
assert_eq!(json["schemaVersion"], 1);
assert_eq!(json["orderSource"], "fallback");
assert_eq!(json["providerState"], "ready");
assert!(json["global"].is_null());
assert!(json["direct"].is_null());
assert!(json["providers"][0].get("vehicleType").is_some());
assert!(json["providers"][0].get("updatedAt").is_none());
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
Ok(())
}
}
+18 -2
View File
@@ -27,6 +27,14 @@ pub struct RealEnv;
impl RunStateEnv for RealEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
// The IPC client would otherwise back off for about two minutes before failing.
#[cfg(windows)]
if let Some(reason) = tokio::task::spawn_blocking(crate::core::service::service_stop_reason)
.await
.context("service status probe did not finish")??
{
anyhow::bail!(reason);
}
let response = clash_verge_service_ipc::get_version().await?;
let core = if response.code == 0
&& response.data.as_ref().is_some_and(|info| {
@@ -62,9 +70,17 @@ impl RunStateEnv for RealEnv {
}
async fn trusted_install_evidence(&self) -> Result<bool> {
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
.await
.context("service registration probe did not finish")?
.context("service registration probe did not finish")??;
// A helper that outlived its registration is a broken Service, not an absent one.
#[cfg(unix)]
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
return Ok(error
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
.is_some());
}
Ok(registered)
}
fn is_elevated(&self) -> bool {
+16 -16
View File
@@ -10,7 +10,7 @@ mod probe;
use std::{
sync::{
Arc,
Arc, LazyLock,
atomic::{AtomicBool, Ordering},
},
time::Duration,
@@ -19,7 +19,6 @@ use std::{
use anyhow::{Context as _, Result, bail};
use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use tokio::sync::Notify;
@@ -28,14 +27,14 @@ pub use env::FakeEnv;
pub use env::{RealEnv, RunStateEnv};
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
use crate::core::manager::RunningMode;
use health::StoredService;
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
/// The process-wide Run State, observing the real machine.
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
pub static RUN_STATE: LazyLock<RunStateStore<RealEnv>> = LazyLock::new(|| RunStateStore::new(RealEnv));
/// Distinguishes silence from a reply that updated Service health with a rejection.
#[derive(Debug)]
@@ -130,20 +129,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
/// Retries transport failures but stops at the first readable reply, including a rejection.
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
let mut last_error = None;
for attempt in 0..attempts {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let attempts = std::num::NonZeroUsize::new(attempts).ok_or_else(|| {
ReadyWaitError::Unreachable(anyhow::anyhow!(
"service readiness wait was configured with no attempts"
))
})?;
retry(RetryPolicy::fixed(attempts, interval), |_| async {
match self.env.probe_service_version().await {
Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected),
Err(error) => last_error = Some(error),
Ok(reply) => self
.record_reply(&reply)
.map_err(|error| RetryError::Stop(ReadyWaitError::Rejected(error))),
Err(error) => Err(RetryError::Retry(ReadyWaitError::Unreachable(error))),
}
if attempt + 1 < attempts {
tokio::time::sleep(interval).await;
}
}
Err(ReadyWaitError::Unreachable(last_error.unwrap_or_else(|| {
anyhow::anyhow!("service readiness wait was configured with no attempts")
})))
})
.await
}
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
+4 -3
View File
@@ -27,6 +27,9 @@ pub enum ServiceVersionCheck {
CoreUnavailable(String),
}
/// Leads the Unavailable reason when the Service refused the approved core itself.
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
@@ -47,9 +50,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!(
"approved core was rejected: {reason}; reinstall the service to repair it"
))
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
+312 -127
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use crate::utils::dirs;
use crate::{
config::{Config, runtime::IRuntime},
@@ -5,12 +6,12 @@ use crate::{
core::{
CoreManager,
handle::Handle,
manager::RunningMode,
manager::{CoreFailure, RunningMode},
owner_identity::current_owner_credentials,
proxy_control,
runstate::{
OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError,
RunState, RunStateEnv, RunStateStore, ServiceHealth,
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
},
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray,
@@ -21,10 +22,10 @@ use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{
MacosProxyConfig, OwnerCredentials, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, RuntimeBundle,
RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, WriterConfig,
MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome,
RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot,
StageRuntimeOutcome, StartClashRequest, WriterConfig,
};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use std::{
collections::HashMap,
@@ -37,24 +38,62 @@ use std::{
};
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::sidecar_fallback", "");
/// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
NotAutoStarted,
}
pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
/// The release installer registers AutoStart.
const SERVICE_NOT_AUTO_STARTED: &str = "the Windows service is stopped and no longer starts with Windows";
pub(crate) fn is_not_auto_started(reason: &str) -> bool {
// Detection prefixes its own context to the reason.
reason.contains(SERVICE_NOT_AUTO_STARTED)
}
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback(reason: &str) {
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
ServiceFallbackNotice::CoreRejected(reason)
} else if is_not_auto_started(&reason) {
ServiceFallbackNotice::NotAutoStarted
} else {
ServiceFallbackNotice::Unavailable
})
}
pub(crate) fn take_service_repair_notice() -> bool {
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
}
/// Returns the command that gives the app data root back to this account.
pub(crate) fn take_service_owner_notice() -> Option<String> {
PENDING_SERVICE_OWNER_NOTICE.lock().take()
}
fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
let OwnerIdentity::Unix { uid, gid } = credentials.identity else {
return None;
};
let path = credentials.app_data_dir.replace('\'', r"'\''");
Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
#[derive(Clone)]
@@ -227,7 +266,7 @@ fn macos_service_install_marker_exists() -> std::io::Result<bool> {
}
#[cfg(windows)]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
fn open_registered_service() -> Result<Option<windows_service::service::Service>> {
use windows_service::{
Error as WindowsServiceError,
service::ServiceAccess,
@@ -238,19 +277,51 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
let manager = WindowsServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
match manager.open_service(
clash_verge_service_ipc::WINDOWS_SERVICE_NAME,
ServiceAccess::QUERY_STATUS,
ServiceAccess::QUERY_STATUS | ServiceAccess::QUERY_CONFIG,
) {
Ok(service) => {
drop(service);
Ok(true)
}
Ok(service) => Ok(Some(service)),
Err(WindowsServiceError::Winapi(error)) if error.raw_os_error() == Some(ERROR_SERVICE_DOES_NOT_EXIST) => {
Ok(false)
Ok(None)
}
Err(error) => Err(error).context("failed to inspect Windows service registration"),
}
}
#[cfg(windows)]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
Ok(open_registered_service()?.is_some())
}
/// Why IPC cannot succeed until the service is started again, if it cannot. A starting service,
/// or an AutoStart one the SCM has yet to start this boot, is left to the IPC retries.
#[cfg(windows)]
pub(crate) fn service_stop_reason() -> Result<Option<&'static str>> {
use windows_service::service::{ServiceExitCode, ServiceStartType, ServiceState};
const ERROR_SERVICE_NEVER_STARTED: u32 = 1077;
const NOT_RUNNING: &str = "the Windows service is not running";
let Some(service) = open_registered_service()? else {
return Ok(Some(NOT_RUNNING));
};
let status = service
.query_status()
.context("failed to query Windows service status")?;
if status.current_state != ServiceState::Stopped {
return Ok(None);
}
// The development channel registers an on-demand start.
if !cfg!(feature = "verge-dev")
&& service
.query_config()
.context("failed to query Windows service configuration")?
.start_type
!= ServiceStartType::AutoStart
{
return Ok(Some(SERVICE_NOT_AUTO_STARTED));
}
Ok((status.exit_code != ServiceExitCode::Win32(ERROR_SERVICE_NEVER_STARTED)).then_some(NOT_RUNNING))
}
#[cfg(target_os = "linux")]
pub(crate) fn trusted_service_evidence() -> Result<bool> {
let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG);
@@ -280,35 +351,40 @@ static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
#[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))]
fn create_service_core_staging_file(directory: &Path, core_name: &std::ffi::OsStr) -> Result<(PathBuf, std::fs::File)> {
for _ in 0..32 {
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
let temporary_name = format!(
".{}.{}.{generation}.tmp",
core_name.to_string_lossy(),
std::process::id()
);
let temporary_path = directory.join(temporary_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)
{
Ok(file) => return Ok((temporary_path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(error).with_context(|| {
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
retry_sync(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(31),
std::time::Duration::ZERO,
),
|_| {
let generation = SERVICE_CORE_STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
let temporary_name = format!(
".{}.{}.{generation}.tmp",
core_name.to_string_lossy(),
std::process::id()
);
let temporary_path = directory.join(temporary_name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary_path)
{
Ok(file) => Ok((temporary_path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(RetryError::Retry(anyhow::anyhow!(
"failed to create a unique temporary development Service core in {}",
directory.display()
)))
}
Err(error) => Err(RetryError::Stop(anyhow::Error::from(error).context({
format!(
"failed to create temporary development Service core {}",
temporary_path.display()
)
});
}))),
}
}
}
bail!(
"failed to create a unique temporary development Service core in {}",
directory.display()
},
)
}
@@ -799,12 +875,22 @@ fn record_service_start_refusal<E: RunStateEnv>(
store: &RunStateStore<E>,
refusal: ServiceStartRefusal,
) -> anyhow::Error {
if store.state().mode == crate::core::manager::RunningMode::NotRunning {
// A proxy clear failure is about the system network settings; repairing the service cannot fix it.
if store.state().mode == crate::core::manager::RunningMode::NotRunning
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16
{
store.observe(ServiceHealth::Unavailable(refusal.to_string()));
}
refusal.into()
}
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
pub(super) fn record_residual_service(error: &anyhow::Error) {
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
}
}
/// 尝试使用服务启动core
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
@@ -842,7 +928,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
#[cfg(target_os = "windows")]
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::repair_required", "");
Handle::notice(NoticeStatus::ServiceCoreRepairRequired, "");
}
if response.code == ServiceErrorCode::AppDataRootNotOwned as u16 {
*PENDING_SERVICE_OWNER_NOTICE.lock() = app_data_owner_command(&credentials);
Handle::notice(NoticeStatus::ServiceCoreAppDataNotOwned, "");
}
start_owner_monitor();
return Err(record_service_start_refusal(
@@ -871,8 +961,9 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor();
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_OWNER_NOTICE.lock().take();
logging!(
info,
Type::Service,
@@ -915,7 +1006,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
if response.code > 0 {
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
}
let err_msg = response.message;
bail!(err_msg);
@@ -933,13 +1024,13 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
let response = response.context("无法连接到Clash Verge Service")?;
if response.code > 0 {
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await;
recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await;
}
bail!(response.message);
}
let encoded = response.data.context("服务未返回核心日志快照")?;
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
Ok(String::from_utf8_lossy(&content).into_owned())
Ok(String::from_utf8_lossy_owned(content))
}
fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
@@ -953,7 +1044,8 @@ fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
}
static PROVIDER_SYNC_QUEUED: AtomicBool = AtomicBool::new(false);
static PROVIDER_SYNC_SERIAL: Lazy<tokio::sync::Mutex<()>> = Lazy::new(|| tokio::sync::Mutex::new(()));
// Serializing the whole read-and-publish pass prevents an older cache from replacing a newer one.
static PROVIDER_SYNC_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
static SYNC_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const RUNTIME_PROVIDER_SYNC_ATTEMPTS: u32 = 4;
const CONTENT_COMPARE_CHUNK: usize = 64 * 1024;
@@ -964,38 +1056,47 @@ pub(crate) fn request_runtime_provider_sync(delay: Duration) {
}
AsyncHandler::spawn(move || async move {
tokio::time::sleep(delay).await;
for attempt in 1..=RUNTIME_PROVIDER_SYNC_ATTEMPTS {
let outcome = {
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
if attempt == 1 {
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let _ = retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add((RUNTIME_PROVIDER_SYNC_ATTEMPTS - 1) as usize),
constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY,
),
|index| async move {
let attempt = index as u32 + 1;
let outcome = {
let _serial = PROVIDER_SYNC_SERIAL.lock().await;
if attempt == 1 {
PROVIDER_SYNC_QUEUED.store(false, Ordering::Release);
}
sync_runtime_providers_by_service().await
};
match outcome {
Ok(ProviderSync { pending: 0, .. }) => Ok(()),
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
logging!(
info,
Type::Service,
"{pending} provider caches are not ready yet; retrying"
);
Err(RetryError::Retry(()))
}
Ok(ProviderSync { pending, .. }) => {
logging!(warn, Type::Service, "{pending} provider caches were not synced");
Ok(())
}
Err(error) => {
logging!(
warn,
Type::Service,
"failed to sync provider caches from the service: {error:#}"
);
Ok(())
}
}
sync_runtime_providers_by_service().await
};
match outcome {
Ok(ProviderSync { pending: 0, .. }) => return,
Ok(ProviderSync { pending, .. }) if attempt < RUNTIME_PROVIDER_SYNC_ATTEMPTS => {
logging!(
info,
Type::Service,
"{pending} provider caches are not ready yet; retrying"
);
}
Ok(ProviderSync { pending, .. }) => {
logging!(warn, Type::Service, "{pending} provider caches were not synced");
return;
}
Err(error) => {
logging!(
warn,
Type::Service,
"failed to sync provider caches from the service: {error:#}"
);
return;
}
}
tokio::time::sleep(constants::timing::RUNTIME_PROVIDER_SYNC_RETRY_DELAY).await;
}
},
)
.await;
});
}
@@ -1303,21 +1404,29 @@ async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
for _ in 0..8 {
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.await
{
Ok(file) => return Ok((temp, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error).with_context(|| format!("failed to create {}", temp.display())),
}
}
bail!("no free temporary name beside {}", target.display())
use crate::utils::retry::{RetryError, RetryPolicy, retry};
retry(
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(7), std::time::Duration::ZERO),
|_| async {
let sequence = SYNC_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temp = target.with_file_name(format!(".{name}.sync-{}-{sequence}.tmp", std::process::id()));
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.await
{
Ok(file) => Ok((temp, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
anyhow::anyhow!("no free temporary name beside {}", target.display()),
)),
Err(error) => Err(RetryError::Stop(
anyhow::anyhow!(error).context(format!("failed to create {}", temp.display())),
)),
}
},
)
.await
}
async fn same_contents(temp: &Path, target: &Path) -> bool {
@@ -1466,9 +1575,11 @@ struct OwnerRecoveryPolicy {
reset_system_proxy: bool,
}
const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
/// The macOS proxy is machine-wide and only the helper may write it, for the session it still
/// honours; a displaced or unreachable owner must leave it alone.
const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy {
OwnerRecoveryPolicy {
reset_system_proxy: !is_macos,
reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure),
}
}
@@ -1490,6 +1601,7 @@ fn start_owner_monitor() {
AsyncHandler::spawn(move || async move {
logging!(debug, Type::Service, "owner monitor started (generation {generation})");
let mut watch = OwnerWatch::new();
let mut core_restarts = None;
loop {
tokio::time::sleep(OWNER_MONITOR_INTERVAL).await;
if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation {
@@ -1509,7 +1621,10 @@ fn start_owner_monitor() {
break;
}
let sample = read_owner_sample().await;
let (sample, status) = read_owner_sample().await;
if let Some(status) = &status {
log_core_restarts(&mut core_restarts, status);
}
let mut step = watch.observe(sample);
if matches!(step, OwnerStep::VerifyTransport) {
if watch.just_became_sustained() {
@@ -1525,15 +1640,41 @@ fn start_owner_monitor() {
}
if let OwnerStep::Recover(reason) = step {
recover_after_owner_loss(generation, reason).await;
recover_after_owner_loss(generation, reason, status.as_ref()).await;
break;
}
}
});
}
/// The Service logs its watchdog restarts where the app cannot read them.
fn log_core_restarts(seen: &mut Option<u32>, status: &ServiceStatusSnapshot) {
if seen.is_some_and(|seen| status.restart_count > seen) {
logging!(
warn,
Type::Service,
"service restarted the core ({} restarts so far); last exit: {}",
status.restart_count,
status.last_core_exit_reason.as_deref().unwrap_or("unknown")
);
}
*seen = Some(status.restart_count);
}
fn report_service_core_stopped(status: &ServiceStatusSnapshot) {
let detail = format!(
"service state {:?}, {} restarts, last exit: {}",
status.service_state,
status.restart_count,
status.last_core_exit_reason.as_deref().unwrap_or("none reported")
);
logging!(error, Type::Service, "service core stopped: {detail}");
CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail));
Handle::notice(NoticeStatus::CoreStartError, "");
}
/// Samples ownership, treating every unusable reply as unreadable.
async fn read_owner_sample() -> OwnerSample {
async fn read_owner_sample() -> (OwnerSample, Option<ServiceStatusSnapshot>) {
let response = match current_owner_credentials() {
Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await,
Err(error) => Err(error),
@@ -1543,12 +1684,12 @@ async fn read_owner_sample() -> OwnerSample {
Ok(response) => response,
Err(error) => {
logging!(debug, Type::Service, "service owner status was unreadable: {error:#}");
return OwnerSample::Unreadable;
return (OwnerSample::Unreadable, None);
}
};
if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 {
return OwnerSample::NotActive;
return (OwnerSample::NotActive, None);
}
if response.code != 0 {
logging!(
@@ -1558,24 +1699,25 @@ async fn read_owner_sample() -> OwnerSample {
response.code,
response.message
);
return OwnerSample::Unreadable;
return (OwnerSample::Unreadable, None);
}
let Some(status) = response.data else {
logging!(debug, Type::Service, "service owner status omitted data");
return OwnerSample::Unreadable;
return (OwnerSample::Unreadable, None);
};
// A session that no longer matches is another owner's, whatever the flags say.
if !session_matches_active_status(status.is_active, status.active_generation) {
return OwnerSample::NotActive;
return (OwnerSample::NotActive, None);
}
OwnerSample::Status {
let sample = OwnerSample::Status {
is_active: status.is_active,
desired_core_should_be_running: status.desired_core_should_be_running,
service_state: status.service_state,
core_pid: status.core_pid,
}
};
(sample, Some(status))
}
fn session_matches_active_status(is_active: bool, active_generation: Option<u64>) -> bool {
@@ -1593,7 +1735,12 @@ pub(crate) fn owner_monitor_generation() -> u64 {
OWNER_MONITOR_GENERATION.load(Ordering::Acquire)
}
async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) {
/// `status` is the sample that led to the recovery, if any; a failed core is reported from it.
async fn recover_after_owner_loss(
generation: u64,
reason: OwnerRecoveryReason,
status: Option<&ServiceStatusSnapshot>,
) {
let manager = CoreManager::global();
if !matches!(*manager.get_running_mode(), RunningMode::Service) {
return;
@@ -1609,6 +1756,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason)
return;
}
recover_after_owner_loss_while_locked(reason).await;
// Still under the lifecycle lock, so a later start is the one that clears it.
if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) {
report_service_core_stopped(status);
}
}
fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option<u64> {
@@ -1633,29 +1784,43 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
);
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
proxy_control::stop_guard().await;
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
// Clear while still in Service mode with the session: on macOS it routes through the helper.
if policy.reset_system_proxy {
clear_proxy_after_owner_loss().await;
}
clear_active_service_session();
CoreManager::global().core_stopped();
if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
return;
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
#[cfg(target_os = "macos")]
if policy.reset_system_proxy {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
let mut last_error = None;
for attempt in 1..=3 {
match proxy_control::clear().await {
Ok(()) => return,
Err(error) => {
async fn clear_proxy_after_owner_loss() {
use crate::utils::retry::{RetryError, RetryPolicy, retry};
let result = retry(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(2),
Duration::from_millis(100),
),
|index| async move {
let attempt = index + 1;
proxy_control::clear().await.map_err(|error| {
logging!(
warn,
Type::Service,
"proxy clear attempt {attempt}/3 after owner loss failed: {error:#}"
);
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
if let Some(error) = last_error {
RetryError::Retry(error)
})
},
)
.await;
if let Err(error) = result {
// The existing recovery path also waits after its final failed clear.
tokio::time::sleep(Duration::from_millis(100)).await;
logging!(
error,
Type::Service,
@@ -2155,13 +2320,16 @@ mod tests {
}
#[test]
fn macos_recovery_never_resets_machine_wide_proxy() {
fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() {
for reason in [
OwnerRecoveryReason::Displaced,
OwnerRecoveryReason::SameOwnerFailure,
OwnerRecoveryReason::TransportFailure,
] {
assert!(!owner_recovery_policy(reason, true).reset_system_proxy);
assert_eq!(
owner_recovery_policy(reason, true).reset_system_proxy,
reason == OwnerRecoveryReason::SameOwnerFailure
);
assert!(owner_recovery_policy(reason, false).reset_system_proxy);
}
@@ -2219,6 +2387,23 @@ mod tests {
Ok(())
}
#[test]
fn proxy_clear_refusal_does_not_ask_for_service_repair() {
let store = fake_store();
store.observe(ServiceHealth::Ready);
let _ = super::record_service_start_refusal(
&store,
super::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::ProxyClearFailed as u16,
core_path: "/development/service-core/verge-mihomo".into(),
message: "SystemConfiguration operation failed: lock preferences (status 3002)".into(),
},
);
assert!(store.state().service_usable());
assert!(!store.state().service_needs_attention());
}
#[test]
fn cached_readiness_is_false_while_a_service_operation_is_running() {
let store = fake_store();
+3 -1
View File
@@ -477,7 +477,9 @@ impl Timer {
Ok(_) => {
logging!(debug, Type::Timer, "timer task completed for uid: {}", uid);
}
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid),
Err(e) => {
logging_error!(Type::Timer, "Failed to update profile uid {}: {e:#}", uid);
}
},
|| Self::emit_update_event(uid, false),
)
+156
View File
@@ -0,0 +1,156 @@
use super::Tray;
use crate::{
Type, cmd,
config::Config,
core::{
handle,
notify::{Refresh, announce},
proxy_view::{ProxyMemberRef, ProxyNodeSource},
},
logging,
};
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
fn is_latency_test_url(raw: &str) -> bool {
let raw = raw.trim();
raw.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
}
pub(super) fn test_url(
group: &crate::core::proxy_view::ProxyGroupView,
verge: &crate::config::IVerge,
profiles: &crate::config::IProfiles,
) -> String {
let custom_url = profiles
.current
.as_ref()
.and_then(|uid| profiles.get_item(uid).ok())
.and_then(|profile| profile.latency_test_urls.as_ref())
.and_then(|urls| urls.get(group.name.as_str()))
.map(|url| url.as_str())
.filter(|url| !url.trim().is_empty());
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
let default_url = verge
.default_latency_test
.as_deref()
.filter(|url| !url.trim().is_empty());
custom_url
.or(group_url)
.or(default_url)
.filter(|&url| is_latency_test_url(url))
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
.trim()
.to_owned()
}
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
parking_lot::Mutex::new(std::collections::BTreeSet::new());
pub(super) fn is_running(group_name: &str) -> bool {
RUNNING.lock().contains(group_name)
}
pub(super) async fn test_proxy_group_delay(group_name: &str) {
if !RUNNING.lock().insert(group_name.to_owned()) {
return;
}
Tray::global().refresh_latency_item(group_name);
let _running = scopeguard::guard(group_name, |name| {
RUNNING.lock().remove(name);
Tray::global().refresh_latency_item(name);
});
use futures::{StreamExt as _, stream};
let view = match cmd::proxy::get_proxy_view().await {
Ok(view) => view,
Err(err) => {
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
return;
}
};
let verge = Config::verge().await.latest_arc();
let group = view
.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == group_name);
let Some(group) = group else {
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
return;
};
let profiles = Config::profiles().await.latest_arc();
let url = test_url(group, &verge, &profiles);
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000) as u32;
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
for member in &group.members {
match member {
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
Some(ProxyNodeSource::Provider {
provider_name,
proxy_name,
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
_ => targets.push((name.clone(), None)),
},
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
ProxyMemberRef::Unresolved { .. } => {}
}
}
let mihomo = handle::Handle::mihomo();
stream::iter(targets.into_iter().map(move |(member, provider)| {
let url = url.clone();
async move {
let result = match provider {
Some(provider_name) => {
mihomo
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
.await
}
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
};
if let Err(err) = result {
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
}
}
}))
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
.for_each(|()| std::future::ready(()))
.await;
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
}
announce(Refresh::Proxies);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_urls_require_a_valid_http_host_and_port() {
for invalid in [
"http://?",
"http://host:invalid",
"http://bad host/",
"ftp://localhost",
"http:localhost",
] {
assert!(!is_latency_test_url(invalid), "{invalid}");
}
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
assert!(is_latency_test_url(valid), "{valid}");
}
}
}
+1
View File
@@ -32,6 +32,7 @@ define_menu! {
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies",
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
+360 -141
View File
@@ -1,20 +1,20 @@
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::module::lightweight;
use crate::process::AsyncHandler;
use crate::singleton;
use crate::utils::window_manager::WindowManager;
use crate::{
Type, cmd,
config::Config,
core::lightweight::is_in_lightweight_mode,
feat, logging,
module::lightweight::is_in_lightweight_mode,
utils::{dirs::find_target_icons, help},
};
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
use super::handle;
@@ -28,6 +28,7 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
};
mod latency;
mod menu_def;
#[cfg(target_os = "macos")]
mod speed_task;
@@ -55,8 +56,25 @@ enum IconKind {
Tun,
}
struct LatencyMenuItem {
item: MenuItem<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray {
limiter: SystemLimiter,
proxy_hover_limiter: SystemLimiter,
menu_update: tokio::sync::Mutex<()>,
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController,
}
@@ -130,6 +148,9 @@ impl Default for Tray {
fn default() -> Self {
Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
menu_update: tokio::sync::Mutex::new(()),
latency_items: parking_lot::Mutex::new(HashMap::new()),
#[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController::new(),
}
@@ -195,6 +216,7 @@ impl Tray {
}
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
let _update = self.menu_update.lock().await;
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(());
@@ -219,29 +241,133 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode();
logging_error!(
Type::Tray,
tray.set_menu(Some(
create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?,
))
);
let (menu, latency_items) = create_tray_menu(
app_handle,
Some(mode.as_str()),
*system_proxy,
*tun_mode,
tun_mode_available,
profiles_preview,
TrayMenuOptions {
is_lightweight_mode,
include_proxy_groups,
},
)
.await?;
tray.set_menu(Some(menu))?;
let groups: Vec<_> = latency_items.keys().cloned().collect();
*self.latency_items.lock() = latency_items;
for group in groups {
self.refresh_latency_item(&group);
}
logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(())
}
async fn refresh_proxy_items(&'static self) {
let Ok(update) = self.menu_update.try_lock() else {
return;
};
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
return;
}
let Ok(Ok(view)) = tokio::time::timeout(
Duration::from_secs(1),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
else {
return;
};
if self
.latency_items
.lock()
.values()
.any(|entry| entry.provider_state != view.provider_state)
{
// Provider fallback changes member identities; rebuild through the existing queue.
drop(update);
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
return;
}
let verge = Config::verge().await.latest_arc();
let profiles = Config::profiles().await.latest_arc();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
// Keep rebuilding serialized until these handles have received their snapshot.
let _update = update;
let mut items = self.latency_items.lock();
for (group_name, entry) in items.iter_mut() {
let Some(group) = find_group(&view, group_name) else {
continue;
};
let url = latency::test_url(group, &verge, &profiles);
let mut members = HashMap::with_capacity(group.members.len());
for member in &group.members {
members
.entry((member_name(member), member_provider(&view, member)))
.or_insert(member);
}
for proxy in &mut entry.proxies {
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
continue;
};
let text = proxy_item_text(&view, member, &url, &verge);
let checked = group.now.as_ref() == Some(&proxy.name);
if text != proxy.text {
match proxy.item.set_text(&text) {
Ok(()) => proxy.text = text,
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
}
}
match proxy.item.is_checked() {
Ok(current) if current != checked => {
logging_error!(Type::Tray, proxy.item.set_checked(checked))
}
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
_ => {}
}
}
}
})
);
}
fn refresh_latency_item(&self, group_name: &str) {
let group_name = group_name.to_owned();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
Self::global().refresh_latency_item_on_main_thread(&group_name);
})
);
}
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
let running = latency::is_running(group_name);
let item = {
let mut items = self.latency_items.lock();
items.get_mut(group_name).and_then(|entry| {
if entry.running == running {
return None;
}
entry.running = running;
Some(entry.item.clone())
})
};
if let Some(item) = item {
let label = if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
clash_verge_i18n::t!("tray.testLatency")
};
logging_error!(Type::Tray, item.set_text(label));
logging_error!(Type::Tray, item.set_enabled(!running));
}
}
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -457,96 +583,175 @@ fn create_profile_menu_item(
.collect()
}
const UNTESTED_DELAY_TEXT: &str = "-ms";
const TIMEOUT_DELAY_TEXT: &str = "T/O";
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
const fn member_name(member: &ProxyMemberRef) -> &str {
match member {
ProxyMemberRef::Node { name, .. }
| ProxyMemberRef::Group { name }
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
}
}
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
view.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == name)
}
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
/// node the core tested on its own still shows a value.
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
view.records
.get(record_id)
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
/// A group row shows the delay of the node the group currently selects. The core never writes a
/// history for the group itself, so `now` is followed until the chain reaches a node.
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
let mut current = group_name;
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
let Some(group) = find_group(view, current) else { break };
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
break;
};
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
break;
};
match member {
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => current = name.as_str(),
ProxyMemberRef::Unresolved { .. } => break,
}
}
find_group(view, group_name)
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
let ProxyMemberRef::Node { record_id, .. } = member else {
return None;
};
match &view.records.get(record_id)?.source {
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
ProxyNodeSource::Core { .. } => None,
}
}
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000);
let delay = match member {
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
ProxyMemberRef::Unresolved { .. } => None,
};
let delay_text = delay.map_or_else(
|| UNTESTED_DELAY_TEXT.to_owned(),
|delay| {
if delay == 0 || u32::from(delay) >= timeout as u32 {
TIMEOUT_DELAY_TEXT.to_owned()
} else {
format!("{delay}ms")
}
},
);
format!("{} | {delay_text}", member_name(member))
}
fn create_subcreate_proxy_menu_item(
app_handle: &AppHandle,
proxy_mode: &str,
proxy_group_order_map: Option<HashMap<String, usize>>,
proxy_nodes_data: Option<Proxies>,
view: Option<ProxyViewV1>,
latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
) -> Vec<Submenu<Wry>> {
let proxy_submenus: Vec<Submenu<Wry>> = {
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new();
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息
if let Some(proxy_nodes_data) = proxy_nodes_data {
for (group_name, group_data) in proxy_nodes_data.proxies.iter() {
let should_show = match proxy_mode {
"global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL",
} && !group_data.hidden.unwrap_or_default();
if !should_show {
continue;
}
let Some(all_proxies) = group_data.all.as_ref() else {
continue;
};
let now_proxy = group_data.now.as_deref().unwrap_or_default();
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
let delay_text = proxy_nodes_data
.proxies
.get(proxy_str)
.and_then(|h| h.history.last())
.map(|h| match h.delay {
0 => "-ms".into(),
delay if delay >= 10000 => "-ms".into(),
_ => format!("{}ms", h.delay),
})
.unwrap_or_else(|| "-ms".into());
let display_text = format!("{} | {}", proxy_str, delay_text);
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
.ok()
})
.collect();
if group_items.is_empty() {
continue;
}
let group_display_name = group_name.to_string();
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group_name),
group_display_name,
true,
&group_items_refs,
) {
let insertion_index = submenus.len();
submenus.push((group_name.into(), insertion_index, submenu));
} else {
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
}
let Some(view) = view else { return Vec::new() };
view.groups
.iter()
.chain(view.global.iter())
.filter_map(|group| {
if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
return None;
}
}
if let Some(order_map) = proxy_group_order_map.as_ref() {
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| {
match (order_map.get(name_a), order_map.get(name_b)) {
(Some(index_a), Some(index_b)) => index_a.cmp(index_b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => original_index_a.cmp(original_index_b),
}
});
}
submenus.into_iter().map(|(_, _, submenu)| submenu).collect()
};
proxy_submenus
let url = latency::test_url(group, verge, profiles);
let proxies: Vec<ProxyLatencyItem> = group
.members
.iter()
.filter_map(|member| {
let name = member_name(member);
let text = proxy_item_text(&view, member, &url, verge);
let checked = group.now.as_deref() == Some(name);
CheckMenuItem::with_id(
app_handle,
format!("proxy_{}_{}", group.name, name),
&text,
true,
checked,
None::<&str>,
)
.map(|item| ProxyLatencyItem {
item,
name: name.to_owned(),
provider_name: member_provider(&view, member).map(str::to_owned),
text,
})
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
.ok()
})
.collect();
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
if group_items.is_empty() {
return None;
}
let running = latency::is_running(&group.name);
let test_item = MenuItem::with_id(
app_handle,
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
texts.test_latency.clone()
},
!running,
None::<&str>,
)
.ok()?;
latency_items.insert(
group.name.clone(),
LatencyMenuItem {
item: test_item.clone(),
running,
proxies,
provider_state: view.provider_state,
},
);
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group.name),
&group.name,
true,
&items,
)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
.ok()
})
.collect()
}
fn create_proxy_menu_item(
@@ -593,54 +798,38 @@ async fn create_tray_menu(
tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> {
) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups {
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime = Config::runtime().await.latest_arc();
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| {
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
// Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let proxy_view = if fetch_proxy_groups {
tokio::time::timeout(
Duration::from_millis(1000),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
.ok()
.and_then(Result::ok)
} else {
(None, None)
None
};
if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc();
}
let profiles_config = Config::profiles().await.latest_arc();
let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode
.as_deref()
.unwrap_or("default");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let proxy_group_order_map = runtime_proxy_groups_order;
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
let version = env!("CARGO_PKG_VERSION");
@@ -720,9 +909,17 @@ async fn create_tray_menu(
&profile_menu_items_refs,
)?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus =
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data);
let proxy_sub_menus = create_subcreate_proxy_menu_item(
app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -866,10 +1063,25 @@ async fn create_tray_menu(
]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok(menu)
Ok((menu, latency_items))
}
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
let refresh_proxies = match tray_event {
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
TrayIconEvent::Click {
button: MouseButton::Left | MouseButton::Right,
button_state: MouseButtonState::Down,
..
} => true,
_ => false,
};
if refresh_proxies {
AsyncHandler::spawn(|| async {
Tray::global().refresh_proxy_items().await;
});
}
if matches!(
tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -926,9 +1138,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
AsyncHandler::spawn(|| async move {
match event.id.as_ref() {
mode @ (MenuIds::RULE_MODE | MenuIds::GLOBAL_MODE | MenuIds::DIRECT_MODE) => {
if let Some(stripped) = mode.strip_prefix("tray_")
&& let Some(final_mode) = stripped.strip_suffix("_mode")
{
if let Some(final_mode) = mode.strip_circumfix("tray_", "_mode") {
logging!(info, Type::ProxyMode, "Switch Proxy Mode To: {}", final_mode);
let _ = feat::change_clash_mode(final_mode.into()).await;
}
@@ -985,6 +1195,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
};
feat::toggle_proxy_profile(profile_index.into()).await;
}
id if id.starts_with(MenuIds::TEST_LATENCY) => {
let group_name = id
.strip_prefix(MenuIds::TEST_LATENCY)
.and_then(|rest| rest.strip_prefix('_'));
if let Some(group_name) = group_name {
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
latency::test_proxy_group_delay(group_name).await;
}
}
id if id.starts_with("proxy_") => {
let rest = match id.strip_prefix("proxy_") {
Some(r) => r,
+310 -116
View File
@@ -2,19 +2,20 @@ use crate::{config::Config, singleton, utils::dirs};
use anyhow::{Context as _, Result};
use chrono::Utc;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::Duration,
};
use tauri_plugin_updater::{Update, UpdaterExt as _};
use tokio_util::sync::CancellationToken;
pub struct SilentUpdater {
update_ready: AtomicBool,
pending_bytes: RwLock<Option<Vec<u8>>>,
pending_update: RwLock<Option<Update>>,
pending_version: RwLock<Option<String>>,
manual_cancel: Mutex<Option<CancellationToken>>,
background_cancel: Mutex<Option<CancellationToken>>,
}
singleton!(SilentUpdater, SILENT_UPDATER);
@@ -23,9 +24,8 @@ impl SilentUpdater {
const fn new() -> Self {
Self {
update_ready: AtomicBool::new(false),
pending_bytes: RwLock::new(None),
pending_update: RwLock::new(None),
pending_version: RwLock::new(None),
manual_cancel: Mutex::new(None),
background_cancel: Mutex::new(None),
}
}
@@ -99,6 +99,72 @@ impl SilentUpdater {
}
}
}
/// Returns the cached installer for `update`, deleting a cache that does not match it.
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
let meta = Self::read_cache_meta().ok()?;
let bytes = if meta.version == update.version {
Self::read_cache_bytes().and_then(|bytes| {
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
Ok(bytes)
})
} else {
Err(anyhow::anyhow!("server offers v{}", update.version))
};
match bytes {
Ok(bytes) => Some(bytes),
Err(e) => {
logging!(
info,
Type::System,
"Update cache v{} is unusable: {e:#}, cleaning up",
meta.version
);
Self::delete_cache();
None
}
}
}
}
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
/// with the same rules `Update::download` applies.
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
use base64::Engine as _;
let decode = |value: &str| -> Result<String> {
Ok(String::from_utf8(
base64::engine::general_purpose::STANDARD.decode(value)?,
)?)
};
let config: tauri_plugin_updater::Config = serde_json::from_value(
app_handle
.config()
.plugins
.0
.get("updater")
.cloned()
.context("updater is not configured")?,
)?;
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
public_key
.verify(bytes, &signature, true)
.context("signature mismatch")?;
match signature
.trusted_comment()
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
{
Some(signed) => anyhow::ensure!(
signed.trim_start_matches('v') == version,
"signed for v{signed}, not v{version}"
),
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
}
Ok(())
}
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
@@ -147,9 +213,128 @@ fn nsis_language_id(app_language: &str) -> &'static str {
}
}
/// Same wire shape as the updater plugin's JS `DownloadEvent`.
#[derive(Clone, Serialize)]
#[serde(tag = "event", content = "data")]
pub enum DownloadEvent {
#[serde(rename_all = "camelCase")]
Started {
content_length: Option<u64>,
},
#[serde(rename_all = "camelCase")]
Progress {
chunk_length: usize,
},
Finished,
}
const MIRRORS: [&str; 2] = ["https://update.hwdns.net/", "https://gh-proxy.org/"];
/// A source delivering less than this per window is dropped for the next one.
const MIN_PROGRESS: u64 = 1024 * 1024;
const PROGRESS_WINDOW: Duration = Duration::from_secs(30);
/// The manifest's URL, then the other mirror, then GitHub itself.
fn download_sources(url: &tauri::Url) -> Vec<tauri::Url> {
let origin = MIRRORS
.iter()
.find_map(|mirror| url.as_str().strip_prefix(mirror))
.unwrap_or(url.as_str());
let mut sources = vec![url.clone()];
if origin.starts_with("https://github.com/") {
let candidates = MIRRORS.iter().map(|mirror| format!("{mirror}{origin}"));
for candidate in candidates.chain([origin.to_owned()]) {
if let Ok(candidate) = tauri::Url::parse(&candidate)
&& !sources.contains(&candidate)
{
sources.push(candidate);
}
}
}
sources
}
/// Tries every source with a throughput floor, then again stopping only on a stall, so a link
/// that is slow everywhere still finishes.
async fn download(update: &Update, on_event: impl Fn(DownloadEvent) + Sync) -> Result<Vec<u8>> {
let sources = download_sources(&update.download_url);
let mut last_error = None;
for floor in [MIN_PROGRESS, 1] {
for url in &sources {
logging!(info, Type::System, "Downloading update v{} from {url}", update.version);
match download_from(update, url, floor, &on_event).await {
Ok(bytes) => {
on_event(DownloadEvent::Finished);
return Ok(bytes);
}
Err(e) => {
logging!(warn, Type::System, "Update download from {url} failed: {e:#}");
last_error = Some(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no update download source")))
}
async fn download_from(
update: &Update,
url: &tauri::Url,
floor: u64,
on_event: &(impl Fn(DownloadEvent) + Sync),
) -> Result<Vec<u8>> {
let mut source = update.clone();
source.download_url = url.clone();
let received = AtomicU64::new(0);
let mut started = false;
let fetch = source.download(
|chunk_length, content_length| {
if !std::mem::replace(&mut started, true) {
on_event(DownloadEvent::Started { content_length });
}
received.fetch_add(chunk_length as u64, Ordering::Relaxed);
on_event(DownloadEvent::Progress { chunk_length });
},
|| {},
);
tokio::pin!(fetch);
let mut seen = 0;
loop {
tokio::select! {
result = &mut fetch => return Ok(result?),
() = tokio::time::sleep(PROGRESS_WINDOW) => {
let total = received.load(Ordering::Relaxed);
anyhow::ensure!(total - seen >= floor, "only {} bytes in {PROGRESS_WINDOW:?}", total - seen);
seen = total;
}
}
}
}
impl SilentUpdater {
/// Checks the server; Windows receives `/LANG` to suppress the NSIS language dialog,
/// see `packages/windows/installer.nsi`.
async fn check(app_handle: &tauri::AppHandle) -> Result<Option<Update>> {
let updater_builder = app_handle.updater_builder();
#[cfg(target_os = "windows")]
let updater_builder = {
let verge_lang = Config::verge().await.latest_arc().language.clone();
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
updater_builder.installer_arg(format!("/LANG={lang_id}"))
};
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
}
/// Installs a newer cached update before normal startup, if the user confirms.
pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool {
if !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
Self::delete_cache();
return false;
}
let current_version = env!("CARGO_PKG_VERSION");
let meta = match Self::read_cache_meta() {
@@ -185,71 +370,30 @@ impl SilentUpdater {
return false;
}
let bytes = match Self::read_cache_bytes() {
Ok(b) => b,
Err(e) => {
let update = match Self::check(app_handle).await {
Ok(Some(u)) => u,
Ok(None) => {
logging!(
warn,
info,
Type::System,
"Failed to read cached update bytes: {e:#}, cleaning up"
"No update available from server, cache may be stale, cleaning up"
);
Self::delete_cache();
return false;
}
};
// Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the
// NSIS language dialog; see `packages/windows/installer.nsi`.
let updater_builder = app_handle.updater_builder();
#[cfg(target_os = "windows")]
let updater_builder = {
let verge_lang = Config::verge().await.latest_arc().language.clone();
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
updater_builder.installer_arg(format!("/LANG={lang_id}"))
};
let update = match updater_builder.build() {
Ok(updater) => match updater.check().await {
Ok(Some(u)) => u,
Ok(None) => {
logging!(
info,
Type::System,
"No update available from server, cache may be stale, cleaning up"
);
Self::delete_cache();
return false;
}
Err(e) => {
logging!(
warn,
Type::System,
"Failed to check for update at startup: {e}, will retry next launch"
);
return false; // Keep cache for next attempt
}
},
Err(e) => {
logging!(
warn,
Type::System,
"Failed to create updater: {e}, will retry next launch"
"Failed to check for update at startup: {e:#}, will retry next launch"
);
return false;
return false; // Keep cache for next attempt
}
};
// A changed server version invalidates the cached bytes.
if update.version != *cached_version {
logging!(
info,
Type::System,
"Server version ({}) != cached version ({}), cache is stale, cleaning up",
update.version,
cached_version
);
Self::delete_cache();
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
return false;
}
};
let version = update.version.clone();
logging!(info, Type::System, "Installing cached update v{version} at startup...");
@@ -257,49 +401,87 @@ impl SilentUpdater {
Self::show_update_splash(app_handle, &version);
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install_result = tokio::task::spawn_blocking({
let bytes = bytes.clone();
let update = update.clone();
move || update.install(&bytes)
});
let success = match tokio::time::timeout(std::time::Duration::from_secs(30), install_result).await {
Ok(Ok(Ok(()))) => {
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
true
}
Ok(Ok(Err(e))) => {
Err(e) => {
logging!(
warn,
Type::System,
"Startup install failed: {e}, will retry next launch"
);
false
}
Ok(Err(e)) => {
logging!(
warn,
Type::System,
"Startup install task panicked: {e}, will retry next launch"
);
false
}
Err(_) => {
logging!(
warn,
Type::System,
"Startup install timed out (30s), will retry next launch"
"Startup install failed: {e:#}, will retry next launch"
);
Self::close_update_splash(app_handle);
false
}
}
}
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
/// `false` when cancelled before the installer starts.
pub async fn install_update(
&self,
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<bool> {
let cancel = CancellationToken::new();
*self.manual_cancel.lock() = Some(cancel.clone());
let prepared = tokio::select! {
prepared = Self::prepare_install(app_handle, version, on_event) => Some(prepared),
() = cancel.cancelled() => None,
};
// `cancel_download` cancels under the lock, so once the token is taken back its state is
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
self.manual_cancel.lock().take();
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
logging!(info, Type::System, "Update v{version} cancelled");
return Ok(false);
};
if !success {
Self::close_update_splash(app_handle);
}
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::delete_cache();
Ok(true)
}
success
async fn prepare_install(
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<(Update, Vec<u8>)> {
let update = Self::check(app_handle)
.await?
.filter(|update| update.version == version)
.with_context(|| format!("v{version} is no longer offered"))?;
let bytes = match Self::verified_cache(app_handle, &update) {
Some(bytes) => bytes,
None => download(&update, on_event).await?,
};
Ok((update, bytes))
}
pub fn cancel_download(&self) {
let mut slot = self.manual_cancel.lock();
if let Some(cancel) = slot.take() {
cancel.cancel();
}
}
/// Drops the downloaded update and aborts a background download in flight.
pub fn discard_pending(&self) {
let cancel = self.background_cancel.lock().take();
if let Some(cancel) = cancel {
cancel.cancel();
}
self.update_ready.store(false, Ordering::Release);
Self::delete_cache();
}
}
@@ -388,12 +570,14 @@ impl SilentUpdater {
// The new webview may not accept evaluation immediately.
std::thread::spawn(move || {
for i in 0..10 {
std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1)));
if window.eval(&js).is_ok() {
return;
}
}
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
let _ = retry_sync(
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(9), std::time::Duration::ZERO),
|index| {
std::thread::sleep(std::time::Duration::from_millis(100 * (index as u64 + 1)));
window.eval(&js).map_err(RetryError::Retry)
},
);
});
logging!(info, Type::System, "Update splash window shown");
@@ -410,6 +594,9 @@ impl SilentUpdater {
impl SilentUpdater {
async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> {
// Registered before reading the switch, so `discard_pending` either cancels it or ran first.
let cancel = CancellationToken::new();
*self.background_cancel.lock() = Some(cancel.clone());
let auto_check = Config::verge().await.latest_arc().auto_check_update.unwrap_or(true);
if !auto_check {
logging!(debug, Type::System, "Silent update skipped: auto_check_update is false");
@@ -447,36 +634,42 @@ impl SilentUpdater {
Type::System,
"Silent updater: breaking change detected in v{version}, notifying frontend"
);
super::handle::Handle::notice_message(
"info",
super::handle::Handle::notice(
super::notify::NoticeStatus::Info,
format!("New version v{version} contains breaking changes. Please update manually."),
);
return Ok(());
}
logging!(info, Type::System, "Silent updater: downloading v{version}...");
let bytes = update
.download(
|chunk_len, content_len| {
logging!(
debug,
Type::System,
"Silent updater download progress: chunk={chunk_len}, total={content_len:?}"
);
},
|| {
logging!(info, Type::System, "Silent updater: download complete");
},
)
.await?;
if Self::verified_cache(app_handle, &update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached");
} else {
logging!(info, Type::System, "Silent updater: downloading v{version}...");
let downloaded = tokio::select! {
bytes = download(&update, |_| {}) => Some(bytes),
() = cancel.cancelled() => None,
};
let Some(bytes) = downloaded else {
logging!(info, Type::System, "Silent updater: download cancelled");
return Ok(());
};
let bytes = bytes?;
logging!(info, Type::System, "Silent updater: download complete");
Self::write_cache(&bytes, &version)?;
}
if let Err(e) = Self::write_cache(&bytes, &version) {
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
// Serialized with `discard_pending` in the verge patch, which holds the same lock.
let _config_write = Config::lock_config_write().await;
if cancel.is_cancelled() || !Config::verge().await.latest_arc().auto_check_update.unwrap_or(true) {
logging!(
info,
Type::System,
"Silent updater: auto check was disabled, discarding v{version}"
);
Self::delete_cache();
return Ok(());
}
*self.pending_bytes.write() = Some(bytes);
*self.pending_update.write() = Some(update);
*self.pending_version.write() = Some(version.clone());
self.update_ready.store(true, Ordering::Release);
logging!(
@@ -488,6 +681,7 @@ impl SilentUpdater {
}
pub async fn start_background_check(&self, app_handle: tauri::AppHandle) {
// Permanent watcher: daily update cycles for the lifetime of the app.
logging!(info, Type::System, "Silent updater: background task started");
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
+3 -5
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
mod chain;
pub mod field;
mod merge;
@@ -436,7 +437,7 @@ fn notify_discarded_keys(keys: Vec<String>) {
drop(notice);
if should_notify {
Handle::notice_message("enhance::discarded_keys", "");
Handle::notice(NoticeStatus::EnhanceDiscardedKeys, "");
}
}
@@ -506,10 +507,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
fn is_loopback_bind_address(addr: &str) -> bool {
let addr = addr.trim();
let addr = addr
.strip_prefix('[')
.and_then(|addr| addr.strip_suffix(']'))
.unwrap_or(addr);
let addr = addr.strip_circumfix('[', ']').unwrap_or(addr);
addr.eq_ignore_ascii_case("localhost")
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
-16
View File
@@ -1,8 +1,5 @@
use serde_yaml_ng::{Mapping, Value};
#[cfg(target_os = "macos")]
use crate::process::AsyncHandler;
macro_rules! revise {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
@@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
}
#[cfg(target_os = "macos")]
{
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
});
}
}
revise!(config, "dns", dns_val);
} else {
#[cfg(target_os = "macos")]
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
});
}
revise!(tun_val, "enable", enable);
+22 -9
View File
@@ -1,3 +1,5 @@
use crate::core::notify::NoticeStatus;
use crate::core::notify::{Refresh, announce};
use crate::{
config::{Config, MixedPort},
core::{CoreManager, handle, tray},
@@ -7,13 +9,12 @@ use crate::{
};
use bytes::BytesMut;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
#[allow(clippy::expect_used)]
static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| {
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
@@ -26,11 +27,10 @@ static TLS_CONFIG: Lazy<Arc<rustls::ClientConfig>> = Lazy::new(|| {
pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await {
Ok(_) => {
handle::Handle::refresh_clash();
handle::Handle::notice_message("set_config::ok", "ok");
announce(Refresh::Clash);
}
Err(err) => {
handle::Handle::notice_message("set_config::error", format!("{err:#}"));
handle::Handle::notice(NoticeStatus::SetConfigError, format!("{err:#}"));
logging!(error, Type::Core, "restart core failed: {err:#}");
}
}
@@ -53,8 +53,21 @@ pub async fn restart_app() {
if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
handle::Handle::notice_message(
"app_restart::core_stop_failed",
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppRestartCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return;
@@ -95,7 +108,7 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
let clash_data = clash.data_arc();
if clash_data.save_config().await.is_ok() {
handle::Handle::refresh_clash();
announce(Refresh::Clash);
tray::Tray::global().update_menu_and_icon().await;
}
+19 -295
View File
@@ -1,279 +1,17 @@
use crate::{
config::{Config, IVerge},
core::{CoreManager, autostart, handle, hotkey, logger, proxy_control, tray},
module::{auto_backup::AutoBackupManager, lightweight},
};
use crate::config::{Config, IVerge};
use anyhow::Result;
use bitflags::bitflags;
use clash_verge_draft::{DraftTransaction, SharedDraft};
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_draft::SharedDraft;
use serde_yaml_ng::Mapping;
use tokio::sync::MutexGuard;
/// Patch Clash configuration
pub async fn patch_clash(patch: &Mapping) -> Result<()> {
Config::clash().await.edit_draft(|d| d.patch_config(patch));
let res = {
// 激活订阅
if patch.get("secret").is_some() || patch.get("external-controller").is_some() {
Config::generate().await?;
CoreManager::global().restart_core().await?;
} else if patch.get("allow-lan").is_some() {
CoreManager::global().update_config_checked().await?;
} else {
if patch.get("mode").is_some() {
tray::Tray::global().update_menu_and_icon().await;
}
CoreManager::global().update_config_checked().await?;
}
handle::Handle::refresh_clash();
<Result<()>>::Ok(())
};
match res {
Ok(()) => {
Config::clash().await.apply();
// 分离数据获取和异步调用
let clash_data = Config::clash().await.data_arc();
clash_data.save_config().await?;
Ok(())
}
Err(err) => {
Config::clash().await.discard();
Err(err)
}
}
}
// Define update flags as bitflags for better performance
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct UpdateFlags: u16 {
const RESTART_CORE = 1 << 0;
const CLASH_CONFIG = 1 << 1;
const VERGE_CONFIG = 1 << 2;
const LAUNCH = 1 << 3;
const SYS_PROXY = 1 << 4;
const SYSTRAY_ICON = 1 << 5;
const HOTKEY = 1 << 6;
const SYSTRAY_MENU = 1 << 7;
const SYSTRAY_TOOLTIP = 1 << 8;
const SYSTRAY_CLICK_BEHAVIOR = 1 << 9;
const LIGHT_WEIGHT = 1 << 10;
const LANGUAGE = 1 << 11;
const LOG_LEVEL = 1 << 12;
const LOG_FILE = 1 << 13;
const GROUP_SYS_TRAY = Self::SYSTRAY_MENU.bits()
| Self::SYSTRAY_TOOLTIP.bits()
| Self::SYSTRAY_ICON.bits();
}
}
fn determine_update_flags(patch: &IVerge) -> UpdateFlags {
let tun_mode = patch.enable_tun_mode;
let auto_launch = patch.enable_auto_launch;
let system_proxy = patch.enable_system_proxy;
let pac = patch.proxy_auto_config;
let pac_content = &patch.pac_file_content;
let proxy_bypass = &patch.system_proxy_bypass;
let language = &patch.language;
let mixed_port = patch.verge_mixed_port;
#[cfg(target_os = "macos")]
let tray_icon = &patch.tray_icon;
#[cfg(not(target_os = "macos"))]
let tray_icon: Option<String> = None;
let common_tray_icon = patch.common_tray_icon;
let sysproxy_tray_icon = patch.sysproxy_tray_icon;
let tun_tray_icon = patch.tun_tray_icon;
#[cfg(not(target_os = "windows"))]
let redir_enabled = patch.verge_redir_enabled;
#[cfg(not(target_os = "windows"))]
let redir_port = patch.verge_redir_port;
#[cfg(target_os = "linux")]
let tproxy_enabled = patch.verge_tproxy_enabled;
#[cfg(target_os = "linux")]
let tproxy_port = patch.verge_tproxy_port;
let socks_enabled = patch.verge_socks_enabled;
let socks_port = patch.verge_socks_port;
let http_enabled = patch.verge_http_enabled;
let http_port = patch.verge_port;
#[cfg(target_os = "macos")]
let enable_tray_speed = patch.enable_tray_speed;
#[cfg(not(target_os = "macos"))]
let enable_tray_speed: Option<bool> = None;
// let enable_tray_icon = patch.enable_tray_icon;
let enable_global_hotkey = patch.enable_global_hotkey;
let tray_event = &patch.tray_event;
let home_cards = patch.home_cards.as_ref();
let enable_auto_light_weight = patch.enable_auto_light_weight_mode;
let enable_external_controller = patch.enable_external_controller;
let tray_proxy_groups_display_mode = &patch.tray_proxy_groups_display_mode;
let tray_inline_outbound_modes = patch.tray_inline_outbound_modes;
let enable_proxy_guard = patch.enable_proxy_guard;
let proxy_guard_duration = patch.proxy_guard_duration;
let log_level = &patch.app_log_level;
let log_max_size = patch.app_log_max_size;
let log_max_count = patch.app_log_max_count;
#[cfg(target_os = "windows")]
let restart_core_needed = socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
|| enable_external_controller.is_some();
#[cfg(not(target_os = "windows"))]
let mut restart_core_needed = socks_enabled.is_some()
|| http_enabled.is_some()
|| socks_port.is_some()
|| http_port.is_some()
|| mixed_port.is_some()
|| enable_external_controller.is_some();
#[cfg(not(target_os = "windows"))]
{
restart_core_needed |= redir_enabled.is_some() || redir_port.is_some();
}
#[cfg(target_os = "linux")]
{
restart_core_needed |= tproxy_enabled.is_some() || tproxy_port.is_some();
restart_core_needed |= tun_mode == Some(true);
}
let mut update_flags = UpdateFlags::empty();
if restart_core_needed {
update_flags.insert(UpdateFlags::RESTART_CORE);
}
if tun_mode.is_some() {
update_flags.insert(UpdateFlags::CLASH_CONFIG | UpdateFlags::GROUP_SYS_TRAY);
}
if enable_global_hotkey.is_some() || home_cards.is_some() {
update_flags.insert(UpdateFlags::VERGE_CONFIG);
}
if auto_launch.is_some() {
update_flags.insert(UpdateFlags::LAUNCH);
}
if system_proxy.is_some() {
update_flags.insert(UpdateFlags::SYS_PROXY | UpdateFlags::GROUP_SYS_TRAY);
}
if proxy_bypass.is_some()
|| pac_content.is_some()
|| pac.is_some()
|| enable_proxy_guard.is_some()
|| proxy_guard_duration.is_some()
{
update_flags.insert(UpdateFlags::SYS_PROXY);
}
if language.is_some() {
update_flags.insert(UpdateFlags::LANGUAGE | UpdateFlags::SYSTRAY_MENU | UpdateFlags::SYSTRAY_TOOLTIP);
}
if common_tray_icon.is_some()
|| sysproxy_tray_icon.is_some()
|| tun_tray_icon.is_some()
|| tray_icon.is_some()
|| enable_tray_speed.is_some()
{
update_flags.insert(UpdateFlags::SYSTRAY_ICON);
}
if patch.hotkeys.is_some() {
update_flags.insert(UpdateFlags::HOTKEY | UpdateFlags::SYSTRAY_MENU);
}
if tray_event.is_some() {
update_flags.insert(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR);
}
if enable_auto_light_weight.is_some() {
update_flags.insert(UpdateFlags::LIGHT_WEIGHT);
}
if tray_proxy_groups_display_mode.is_some() {
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
}
if log_level.is_some() {
update_flags.insert(UpdateFlags::LOG_LEVEL);
}
if log_max_size.is_some() || log_max_count.is_some() {
update_flags.insert(UpdateFlags::LOG_FILE);
}
if tray_inline_outbound_modes.is_some() {
update_flags.insert(UpdateFlags::SYSTRAY_MENU);
}
update_flags
}
#[allow(clippy::cognitive_complexity)]
async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> Result<()> {
// Process updates based on flags
if update_flags.contains(UpdateFlags::RESTART_CORE) {
Config::generate().await?;
CoreManager::global().restart_core().await?;
}
if update_flags.contains(UpdateFlags::CLASH_CONFIG) {
CoreManager::global().update_config_checked().await?;
handle::Handle::refresh_clash();
}
if update_flags.contains(UpdateFlags::LAUNCH) {
autostart::update_launch().await?;
}
if update_flags.contains(UpdateFlags::LANGUAGE)
&& let Some(language) = &patch.language
{
clash_verge_i18n::set_locale(language.as_str());
}
if update_flags.contains(UpdateFlags::SYS_PROXY) {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
// Turning it off only writes OS state, so it must stay available while the Core is down.
if Config::verge()
.await
.latest_arc()
.enable_system_proxy
.unwrap_or_default()
{
manager.apply_proxy_after_start().await?;
} else {
proxy_control::apply().await?;
proxy_control::refresh_guard().await?;
}
}
if update_flags.contains(UpdateFlags::HOTKEY)
&& let Some(hotkeys) = &patch.hotkeys
{
hotkey::Hotkey::global().update(hotkeys.to_owned()).await?;
}
if update_flags.contains(UpdateFlags::SYSTRAY_MENU) {
tray::Tray::global().update_menu().await?;
}
if update_flags.contains(UpdateFlags::SYSTRAY_ICON) {
tray::Tray::global()
.update_icon(&Config::verge().await.latest_arc())
.await?;
#[cfg(target_os = "macos")]
if patch.enable_tray_speed.is_some() {
tray::Tray::global().update_speed_task(patch.enable_tray_speed.unwrap_or(false));
}
}
if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) {
tray::Tray::global().update_tooltip().await?;
}
if update_flags.contains(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR) {
tray::Tray::global().update_click_behavior().await?;
}
if update_flags.contains(UpdateFlags::LIGHT_WEIGHT) {
if patch.enable_auto_light_weight_mode.unwrap_or(false) {
lightweight::enable_auto_light_weight_mode().await;
} else {
lightweight::disable_auto_light_weight_mode();
}
}
if update_flags.contains(UpdateFlags::LOG_LEVEL) {
logger::Logger::global().update_log_level(patch.get_log_level())?;
}
if update_flags.contains(UpdateFlags::LOG_FILE) {
let log_max_size = patch.app_log_max_size.unwrap_or(128);
let log_max_count = patch.app_log_max_count.unwrap_or(8);
logger::update_log_config(log_max_size, log_max_count).await?;
}
Ok(())
let config_write = Config::try_lock_config_write()?;
super::executor::apply(
&config_write,
super::executor::Patch::Clash(patch),
super::effects::clash_effects(patch),
)
.await
}
/// Apply a patch, then reconcile TUN when its setting changes.
@@ -289,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
/// Apply a patch without post-update reconciliation.
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
let config_write = Config::lock_config_write().await;
let config_write = Config::try_lock_config_write()?;
apply_verge_patch_locked(&config_write, patch, not_save_file).await
}
@@ -300,29 +38,15 @@ pub(super) async fn apply_verge_patch_locked(
patch: &IVerge,
not_save_file: bool,
) -> Result<()> {
let verge = Config::verge().await;
// Hold the claim across side effects so concurrent transactions cannot share this draft.
let transaction = DraftTransaction::begin(vec![&verge])?;
verge.edit_draft(|d| d.patch_config(patch));
let update_flags = determine_update_flags(patch);
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
// A failed patch rolls back to what the user already had; it never invents a value for them.
process_terminated_flags(update_flags, patch).await?;
transaction.commit();
announce_verge_change();
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
if !not_save_file {
// 分离数据获取和异步调用
let verge_data = verge.data_arc();
verge_data.save_file().await?;
}
Ok(())
}
fn announce_verge_change() {
handle::Handle::refresh_verge();
super::executor::apply(
_config_write,
super::executor::Patch::Verge {
patch,
persist: !not_save_file,
},
super::effects::verge_effects(patch),
)
.await
}
pub async fn fetch_verge_config() -> Result<SharedDraft<IVerge>> {
+23 -16
View File
@@ -359,24 +359,31 @@ fn stage_core(target: &Path, package: &[u8], version: &str) -> Result<StagedCore
}
fn create_staging_file(directory: &Path, core_name: &OsStr) -> Result<(PathBuf, File)> {
for _ in 0..32 {
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
let path = directory.join(format!(
".{}.{}.{generation}.tmp",
core_name.to_string_lossy(),
std::process::id()
));
use crate::utils::retry::{RetryError, RetryPolicy, retry_sync};
retry_sync(
RetryPolicy::fixed(
std::num::NonZeroUsize::MIN.saturating_add(31),
std::time::Duration::ZERO,
),
|_| {
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
let path = directory.join(format!(
".{}.{}.{generation}.tmp",
core_name.to_string_lossy(),
std::process::id()
));
match File::options().write(true).create_new(true).open(&path) {
Ok(file) => return Ok((path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(error).with_context(|| format!("failed to create {}", path.display()));
match File::options().write(true).create_new(true).open(&path) {
Ok(file) => Ok((path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(RetryError::Retry(
anyhow::anyhow!("failed to create a unique staging file in {}", directory.display()),
)),
Err(error) => Err(RetryError::Stop(
anyhow::Error::from(error).context(format!("failed to create {}", path.display())),
)),
}
}
}
bail!("failed to create a unique staging file in {}", directory.display())
},
)
}
#[cfg(windows)]
+7 -4
View File
@@ -1,3 +1,4 @@
use crate::core::notify::{Refresh, announce};
use anyhow::{Result, ensure};
use clash_verge_draft::DraftTransaction;
use serde::Serialize;
@@ -10,7 +11,7 @@ use crate::{
profiles::PROFILE_WRITE_LOCK,
},
constants::files::DNS_CONFIG,
core::{CoreManager, handle::Handle},
core::CoreManager,
utils::dirs,
};
@@ -74,8 +75,10 @@ pub async fn set_dns_override(
}
transaction.commit();
Handle::refresh_verge();
Handle::refresh_clash();
verge.data_arc().save_file().await?;
// Core and memory state remain applied even when persistence fails.
let result = verge.data_arc().save_file().await;
announce(Refresh::Verge);
announce(Refresh::Clash);
result?;
Ok(outcome)
}
+289
View File
@@ -0,0 +1,289 @@
use crate::config::IVerge;
use std::collections::BTreeSet;
/// Variant order defines execution dependency order.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum Effect {
RestartCore,
ClashConfig,
Autostart,
Language,
SystemProxy,
Hotkey,
TrayMenu,
TrayIcon,
TrayTooltip,
TrayClick,
Lightweight,
LogLevel,
LogFile,
}
pub(super) type Effects = BTreeSet<Effect>;
macro_rules! select_field {
($effects:ident, $field:ident, NoEffect) => { let _ = $field; };
($effects:ident, $field:ident, [$($effect:ident),*]) => {
if $field.is_some() { $effects.extend([$(Effect::$effect),*]); }
};
($effects:ident, $field:ident, Tun) => {
select_field!($effects, $field, [ClashConfig, TrayMenu, TrayTooltip, TrayIcon]);
#[cfg(target_os = "linux")]
if *$field == Some(true) { $effects.insert(Effect::RestartCore); }
};
}
macro_rules! verge_registry {
($( $(#[$cfg:meta])* $field:ident => $rule:tt; )*) => {
pub(super) fn verge_effects(patch: &IVerge) -> Effects {
// No rest pattern: even fields omitted by serde must be classified at compile time.
let IVerge { $( $(#[$cfg])* $field, )* } = patch;
let mut effects = Effects::new();
$( $(#[$cfg])* { select_field!(effects, $field, $rule); } )*
effects
}
};
}
verge_registry! {
app_log_level => [LogLevel];
app_log_max_size => [LogFile];
app_log_max_count => [LogFile];
language => [Language, TrayMenu, TrayTooltip];
theme_mode => NoEffect;
tray_event => [TrayClick];
env_type => NoEffect;
start_page => NoEffect;
startup_script => NoEffect;
traffic_graph => NoEffect;
enable_memory_usage => NoEffect;
enable_group_icon => NoEffect;
pause_render_traffic_stats_on_blur => NoEffect;
common_tray_icon => [TrayIcon];
#[cfg(target_os = "macos")]
tray_icon => [TrayIcon];
menu_icon => NoEffect;
menu_order => NoEffect;
proxy_group_tools_position => NoEffect;
notice_position => NoEffect;
collapse_navbar => NoEffect;
sysproxy_tray_icon => [TrayIcon];
tun_tray_icon => [TrayIcon];
enable_tun_mode => Tun;
enable_auto_launch => [Autostart];
enable_silent_start => NoEffect;
enable_system_proxy => [SystemProxy, TrayMenu, TrayTooltip, TrayIcon];
enable_proxy_guard => [SystemProxy];
enable_bypass_check => NoEffect;
enable_dns_settings => NoEffect;
profile_dns_settings => NoEffect;
use_default_bypass => [SystemProxy];
system_proxy_bypass => [SystemProxy];
proxy_guard_duration => [SystemProxy];
proxy_auto_config => [SystemProxy];
pac_file_content => [SystemProxy];
proxy_host => [SystemProxy];
theme_setting => NoEffect;
web_ui_list => NoEffect;
clash_core => NoEffect;
hotkeys => [Hotkey, TrayMenu];
enable_global_hotkey => NoEffect;
home_cards => NoEffect;
auto_close_connection => NoEffect;
auto_check_update => NoEffect;
default_latency_test => NoEffect;
default_latency_timeout => NoEffect;
enable_auto_delay_detection => NoEffect;
auto_delay_detection_interval_minutes => NoEffect;
enable_builtin_enhanced => NoEffect;
proxy_layout_column => NoEffect;
test_list => NoEffect;
auto_log_clean => NoEffect;
enable_auto_backup_schedule => NoEffect;
auto_backup_interval_hours => NoEffect;
auto_backup_on_change => NoEffect;
#[cfg(not(target_os = "windows"))]
verge_redir_port => [RestartCore];
#[cfg(not(target_os = "windows"))]
verge_redir_enabled => [RestartCore];
#[cfg(target_os = "linux")]
verge_tproxy_port => [RestartCore];
#[cfg(target_os = "linux")]
verge_tproxy_enabled => [RestartCore];
verge_mixed_port => [RestartCore];
verge_socks_port => [RestartCore];
verge_socks_enabled => [RestartCore];
verge_port => [RestartCore];
verge_http_enabled => [RestartCore];
webdav_url => NoEffect;
webdav_username => NoEffect;
webdav_password => NoEffect;
#[cfg(target_os = "macos")]
enable_tray_speed => [TrayIcon];
tray_proxy_groups_display_mode => [TrayMenu];
tray_inline_outbound_modes => [TrayMenu];
enable_auto_light_weight_mode => [Lightweight];
auto_light_weight_minutes => NoEffect;
enable_hover_jump_navigator => NoEffect;
hover_jump_navigator_delay => NoEffect;
enable_external_controller => [RestartCore];
}
pub(super) fn clash_effects(patch: &serde_yaml_ng::Mapping) -> Effects {
use clash_verge_logging::{Type, logging};
let mut effects = Effects::new();
for key in patch.keys() {
effects.extend(match key.as_str() {
Some("secret" | "external-controller") => &[Effect::RestartCore][..],
Some("allow-lan") => &[Effect::ClashConfig][..],
Some("mode") => &[Effect::ClashConfig, Effect::TrayMenu, Effect::TrayIcon][..],
_ => {
logging!(
warn,
Type::Config,
"Unregistered Clash configuration key {key:?}; reloading configuration"
);
&[Effect::ClashConfig][..]
}
});
}
// These branches were exclusive: restart supersedes reload, and allow-lan precedes mode.
if effects.contains(&Effect::RestartCore) {
effects.retain(|effect| *effect == Effect::RestartCore);
} else if patch.contains_key("allow-lan") {
effects.retain(|effect| *effect == Effect::ClashConfig);
}
if patch.is_empty() {
effects.insert(Effect::ClashConfig);
}
effects
}
#[cfg(test)]
mod tests {
use super::*;
fn verge_patch(mutate: impl FnOnce(&mut IVerge)) -> IVerge {
let mut patch = IVerge::default();
mutate(&mut patch);
patch
}
#[test]
fn representative_verge_patches_map_to_frozen_effects() {
assert_eq!(verge_effects(&IVerge::default()), Effects::new());
let cases: [(IVerge, &[Effect]); 9] = [
(
verge_patch(|p| p.verge_mixed_port = Some(27899)),
&[Effect::RestartCore],
),
(
verge_patch(|p| p.language = Some("zh".into())),
&[Effect::TrayMenu, Effect::TrayTooltip, Effect::Language],
),
(
verge_patch(|p| p.hotkeys = Some(vec!["CmdOrCtrl+K".into()])),
&[Effect::Hotkey, Effect::TrayMenu],
),
(
verge_patch(|p| p.enable_system_proxy = Some(true)),
&[
Effect::SystemProxy,
Effect::TrayIcon,
Effect::TrayMenu,
Effect::TrayTooltip,
],
),
(
verge_patch(|p| p.system_proxy_bypass = Some("localhost".into())),
&[Effect::SystemProxy],
),
(
verge_patch(|p| p.use_default_bypass = Some(false)),
&[Effect::SystemProxy],
),
(
verge_patch(|p| p.proxy_host = Some("localhost".into())),
&[Effect::SystemProxy],
),
(
verge_patch(|p| p.tray_event = Some("show_main_window".into())),
&[Effect::TrayClick],
),
(verge_patch(|p| p.theme_mode = Some("dark".into())), &[]),
];
for (patch, expected) in cases {
assert_eq!(
verge_effects(&patch),
expected.iter().copied().collect::<Effects>(),
"patch mapping drifted for {expected:?}"
);
}
let tun = verge_patch(|p| p.enable_tun_mode = Some(true));
#[cfg(target_os = "linux")]
assert_eq!(
verge_effects(&tun),
[
Effect::RestartCore,
Effect::ClashConfig,
Effect::TrayIcon,
Effect::TrayMenu,
Effect::TrayTooltip,
]
.into_iter()
.collect::<Effects>()
);
#[cfg(not(target_os = "linux"))]
assert_eq!(
verge_effects(&tun),
[
Effect::ClashConfig,
Effect::TrayIcon,
Effect::TrayMenu,
Effect::TrayTooltip,
]
.into_iter()
.collect::<Effects>()
);
}
fn clash_mapping(pairs: &[(&str, &str)]) -> serde_yaml_ng::Mapping {
let mut mapping = serde_yaml_ng::Mapping::new();
for (key, value) in pairs {
mapping.insert((*key).into(), (*value).into());
}
mapping
}
#[test]
fn clash_keys_map_to_frozen_effects() {
let cases: [(serde_yaml_ng::Mapping, &[Effect]); 7] = [
(clash_mapping(&[]), &[Effect::ClashConfig]),
(clash_mapping(&[("secret", "s")]), &[Effect::RestartCore]),
(
clash_mapping(&[("mode", "rule")]),
&[Effect::ClashConfig, Effect::TrayIcon, Effect::TrayMenu],
),
(clash_mapping(&[("allow-lan", "true")]), &[Effect::ClashConfig]),
(clash_mapping(&[("not-a-known-key", "1")]), &[Effect::ClashConfig]),
// Restart supersedes reload; allow-lan precedes mode.
(
clash_mapping(&[("secret", "s"), ("mode", "rule")]),
&[Effect::RestartCore],
),
(
clash_mapping(&[("allow-lan", "true"), ("mode", "rule")]),
&[Effect::ClashConfig],
),
];
for (patch, expected) in cases {
assert_eq!(
clash_effects(&patch),
expected.iter().copied().collect::<Effects>(),
"clash mapping drifted for {expected:?}"
);
}
}
}

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