Compare commits

...
67 Commits
Author SHA1 Message Date
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
7965711945 chore(deps): update cargo dependencies (#8323)
* chore(deps): update cargo dependencies

* chore(deps): revert dependency rfd to 0.16.0

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

---------

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

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

* fix(eslint): Clean unused imports

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

---------

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

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

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

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

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

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

* fix: clippy

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

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

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

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

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

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

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

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-12 10:25:58 +00:00
Tunglies 6e08a77a84 refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta

* fix(profile): project IPC inputs to consumed fields

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

* fix(proxy): confirm runtime chain updates before UI changes

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 27efaf34f4 chore(deps): update dependency pnpm to v12.11.2 (#8252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 16:06:46 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 585c67f8c5 chore(deps): update rust crate zip to v9 (#8248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 13:23:40 +00:00
oomeowand❤是纱雾酱哟~ d6a2717fc4 fix(profiles): skip reloading current profile on URL import (#8092)
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-11 12:01:50 +00:00
MrHardandClaude Opus 5.5 84312a5cd1 fix(sysproxy): bypass *.local by default on Windows (#8054)
Hostnames such as mi13.local are resolved by mDNS on the local network,
which the proxy core cannot do, so they failed with 'couldn't find ip'
while the system proxy was on. The macOS default already bypasses
*.local.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:32:27 +00:00
Magicapple 63ee674dcb fix(ui): disable auto-capitalization for rule content input (#8052)
On macOS, WKWebView applies system auto-capitalization to text inputs, so
typing a lowercase rule payload (e.g. a domain) in the rule editor dialog
turned its first letter into uppercase and required a manual correction.

Apply the same input-hint attributes already used by other text fields in
the app (autoComplete/autoCorrect/autoCapitalize/spellCheck) to the rule
content field.

Fixes #8038
2026-10-10 15:40:21 +00:00
wonfen 52dca35acb ci(signpath): add manual Windows test signing workflow (#8239) 2026-10-10 09:16:30 +00:00
wonfen 44477d97d4 fix(deps): remove obsolete tray-icon patch 2026-10-09 13:53:14 +08:00
wonfen 0fefa7a7ff chore: bump version 2026-10-09 13:43:50 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b6d94d45d5 chore(deps): update dependency pnpm to v12.10.1 (#8217)
* chore(deps): update dependency pnpm to v12.10.1

* fix(knip): remove unnecessary function `export`

Remove export for function `readLegacyTestUrls`, as it didn't be imported somewhere else

Assisted-by: vscode-copilot-sdk:copilot:claude-haiku-5.5 codegraph
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-09 04:11:48 +00:00
aq 9975322b9b fix(ci): handle plain Copilot 429 in PR review (#8116)
* fix(ci): handle plain Copilot 429 in PR review

Recognize unclassified CLI rate-limit output in the existing non-blocking quota guard. Regenerate the pinned gh-aw workflow so review checks do not fail when Copilot cannot accept a request.

* fix(ci): avoid duplicate quota predicates in Copilot review
2026-10-09 03:05:12 +00:00
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
313 changed files with 12913 additions and 6259 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0", "version": "24.22.0",
"pnpmVersion": "12.8.1" "pnpmVersion": "12.14.0"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1", "version": "1.99.1",
"profile": "default" "profile": "default"
}, },
"ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/git:1": {},
+10 -10
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.98.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -185,7 +185,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.98.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -282,7 +282,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -426,7 +426,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -524,7 +524,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.0
name: Install pnpm name: Install pnpm
+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
@@ -43,7 +43,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.98.1 uses: dtolnay/rust-toolchain@1.99.1
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
run: | run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
+72 -1
View File
@@ -6,6 +6,7 @@ on:
permissions: permissions:
contents: read contents: read
pull-requests: read
env: env:
HUSKY: 0 HUSKY: 0
@@ -51,7 +52,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: '24.21.0' node-version: '24.22.0'
cache: 'pnpm' cache: 'pnpm'
- name: Restore pnpm cache - name: Restore pnpm cache
@@ -85,3 +86,73 @@ jobs:
- name: Run Knip - name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: scripts/contract-inputs.json
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.22.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true'
run: pnpm gen:contract --check
+2 -2
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ff92739239d50701d56d10eb77fff5083a556fe471496674aa066c4f73b6b6d2","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}} # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8296ef86aad04ad225d29c7fe51dd18dfa50f258c46e8bd24e443193cc6f80fc","body_hash":"e7b165b458a07108c2e6bfe6f4ed61f5659ce81512691dda091165d5966ee732","compiler_version":"v0.89.21","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.87"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"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"},{"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"},{"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"},{"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"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"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"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_DEFAULT_OTLP_ENDPOINT","GH_AW_DEFAULT_OTLP_HEADERS","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"924af5fdc64061cfbf66fb584c8b07e2ac230c60","version":"v0.89.21"}],"containers":[{"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"},{"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"},{"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"},{"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"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e","pinned_image":"ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"},{"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"}],"has_pull_request_target":true,"mcp_servers":[{"name":"github","tools":["get_pull_request","get_pull_request_comments","get_pull_request_diff","get_pull_request_files","get_pull_request_review_comments","get_pull_request_reviews","get_pull_request_status","issue_read","list_issue_types","list_issues","list_pull_requests","pull_request_read","search_issues","search_pull_requests"]},{"name":"safeoutputs","tools":["add_comment","add_labels","missing_data","missing_tool","noop","remove_labels"]}]}
# This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # This file was automatically generated by gh-aw (v0.89.21). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
# #
@@ -541,7 +541,7 @@ jobs:
GH_AW_SKILL_DIR: ".github/skills" GH_AW_SKILL_DIR: ".github/skills"
run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
- name: Make Copilot quota exhaustion non-blocking - name: Make Copilot quota exhaustion non-blocking
run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' return { action: \"stop\", exitCode: 0 };',\n ' }',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE" run: "node <<'NODE'\nconst fs = require('fs');\nconst harnessPath = `${process.env.RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs`;\nconst harness = fs.readFileSync(harnessPath, 'utf8');\nconst quotaBranch = [\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' return { action: \"stop\" };',\n ' }',\n].join('\\n');\nconst nonBlockingQuotaBranch = [\n ' let quotaErrorMsg = null;',\n ' if (isQuotaExceeded) {',\n ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',\n ' quotaErrorMsg = { action: \"stop\" };',\n ' }',\n ' if (/\\\\b429\\\\b|Too Many Requests/i.test(result.output)) {',\n ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',\n ' quotaErrorMsg = { action: \"stop\", exitCode: 0 };',\n ' }',\n ' if (quotaErrorMsg) {',\n ' return quotaErrorMsg;',\n ' }',\n].join('\\n');\n\nif (harness.split(quotaBranch).length !== 2) {\n throw new Error('Unexpected Copilot quota branch; re-audit the gh-aw upgrade');\n}\nfs.writeFileSync(harnessPath, harness.replace(quotaBranch, nonBlockingQuotaBranch));\nNODE"
- name: Download container images - name: Download container images
run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6 run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2 ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64 ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0 ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086 ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6
+9 -5
View File
@@ -41,13 +41,17 @@ pre-agent-steps:
' }', ' }',
].join('\n'); ].join('\n');
const nonBlockingQuotaBranch = [ const nonBlockingQuotaBranch = [
' let quotaErrorMsg = null;',
' if (isQuotaExceeded) {', ' if (isQuotaExceeded) {',
' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);', ' log(`attempt ${attempt + 1}: Copilot quota exceeded — not retrying`);',
' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {', ' quotaErrorMsg = { action: "stop" };',
' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);', ' }',
' return { action: "stop", exitCode: 0 };', ' if (/\\b429\\b|Too Many Requests/i.test(result.output)) {',
' }', ' log(`attempt ${attempt + 1}: Copilot quota unavailable — exiting 0`);',
' return { action: "stop" };', ' quotaErrorMsg = { action: "stop", exitCode: 0 };',
' }',
' if (quotaErrorMsg) {',
' return quotaErrorMsg;',
' }', ' }',
].join('\n'); ].join('\n');
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.98.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -230,7 +230,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.98.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.98.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -466,7 +466,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.98.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -547,7 +547,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -573,7 +573,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -638,7 +638,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+90
View File
@@ -0,0 +1,90 @@
name: SignPath Test
on:
workflow_dispatch:
permissions:
contents: read
actions: read
env:
CARGO_INCREMENTAL: 0
HUSKY: 0
jobs:
sign-windows:
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare build resources
run: pnpm run prebuild x86_64-pc-windows-msvc
- name: Build test installer
shell: pwsh
env:
NODE_OPTIONS: '--max-old-space-size=4096'
run: |
$configPath = Join-Path $env:RUNNER_TEMP 'signpath-test.json'
'{"bundle":{"createUpdaterArtifacts":false}}' |
Set-Content -LiteralPath $configPath -Encoding utf8
pnpm exec tauri build --target x86_64-pc-windows-msvc --bundles nsis --config $configPath
- name: Upload unsigned installer
id: unsigned
uses: actions/upload-artifact@v7
with:
name: windows-x64-unsigned
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
if-no-files-found: error
- name: Sign with SignPath test certificate
uses: signpath/github-action-submit-signing-request@v3
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '5b298f4d-010d-4bae-b665-70551a58839c'
project-slug: 'clash-verge-rev'
signing-policy-slug: 'test-signing'
artifact-configuration-slug: 'initial'
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
- name: Inspect installer signature
shell: pwsh
run: |
Get-ChildItem -Path signed/*-setup.exe | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if (-not $signature.SignerCertificate) {
throw "No Authenticode signer certificate found for $($_.Name)"
}
$signature | Format-List Path, Status, StatusMessage, SignerCertificate
}
- name: Upload signed test installer
uses: actions/upload-artifact@v7
with:
name: windows-x64-test-signed
path: signed/*-setup.exe
if-no-files-found: error
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.21.0' node-version: '24.22.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+1
View File
@@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
cargo install --force cargo-make cargo install --force cargo-make
fi fi
node scripts/check-contract-changes.mjs --push "$1" || exit $?
cargo make pre-push cargo make pre-push
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0 nodejs 24.22.0
pnpm 12.8.1 pnpm 12.14.0
rust 1.98.1 rust 1.99.1
Generated
+394 -238
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -47,10 +47,8 @@ codegen-units = 1
split-debuginfo = "unpacked" split-debuginfo = "unpacked"
[patch.crates-io] [patch.crates-io]
# macOS Golden Gate fix for tray-icon click reentrancy issue # Backport Windows menu command ID recycling on the muda 0.20 compatibility branch.
# Remove the following patch once the fix is merged upstream(tauri) muda = { git = "https://github.com/wonfen/muda.git", rev = "547de49352791653fbdfa785f20f3c7ece8468e2" }
# https://github.com/tauri-apps/tray-icon/pull/365
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
[workspace.dependencies] [workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-draft = { path = "crates/clash-verge-draft" }
@@ -63,13 +61,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" } tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44" tracing = "0.1.44"
tauri = { version = "2.11.6" } tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.3.3" tauri-plugin-clipboard-manager = "2.4.1"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] } criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.1", features = [ tokio = { version = "1.53.3", features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"time", "time",
@@ -90,7 +88,7 @@ rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" } smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" } serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" } bitflags = { version = "2.13.2" }
socket2 = "0.6.5" socket2 = "0.6.5"
+10 -15
View File
@@ -1,35 +1,30 @@
## v2.5.7 ## v2.5.9
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题 - 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🖥️ Windows** **🐧 Linux**
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题 - Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS** </details>
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题 <details>
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错 <summary><strong> ✨ 新增功能 </strong></summary>
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details> </details>
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows** **🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接 - Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details> </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"> <p align="center">
Languages: Languages:
<a href="./README.md">简体中文</a> ·
<a href="./docs/README_en.md">English</a> · <a href="./docs/README_en.md">English</a> ·
<a href="./docs/README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./docs/README_ko.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> </p>
## Preview ## 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> 请到发布页面下载对应的安装包:[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> 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). 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) ### ✈️ [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云边 -- 全新架构机场。
- 💻 多次**技术迭代后**全新亮相。 - 💻 多次**技术迭代后**全新亮相。
- 🗺 全**高速稳定**正价节点。 - 🗺 全**高速稳定**正价节点。
- 🌏 **海外团队**,不跑路 - 🌏 **海外团队**,不跑路
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁 - 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
- 👨‍🦲 团队架构师为**大厂**网络架构师 - 👨‍🦲 团队架构师为**大厂**网络架构师
- 💰 极致**稳定**,亲民价**价格** - 💰 极致**稳定**,亲民价**价格**
- 🌐 全面支持**流媒体及各AI访问** - 🌐 全面支持**流媒体及各AI访问**
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
rust-i18n = "4.2.2" rust-i18n = "4.2.4"
sys-locale = "0.3.2" sys-locale = "0.3.2"
[lints] [lints]
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول. adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول. adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
core:
alreadyRunning: 'تعذّر بدء النواة: توجد عملية نواة قيد التشغيل بالفعل. أغلق التطبيق المعني أو أنهِ العملية المتبقية، ثم أعد المحاولة. إذا استمرت المشكلة، فأعد تشغيل الكمبيوتر.'
launchGuard: launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}' currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}' homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
@@ -68,6 +70,8 @@ tray:
global: عام global: عام
profiles: الملفات التعريفية profiles: الملفات التعريفية
proxies: وكلاء proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام systemProxy: وكيل النظام
tunMode: وضع TUN tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات closeAllConnections: إغلاق كل الاتصالات
+12 -8
View File
@@ -8,8 +8,8 @@ notifications:
body: Auf {mode} umgeschaltet. body: Auf {mode} umgeschaltet.
systemProxyToggled: systemProxyToggled:
title: Systemproxy title: Systemproxy
'on': System proxy has been enabled. 'on': Der Systemproxy wurde aktiviert.
'off': System proxy has been disabled. 'off': Der Systemproxy wurde deaktiviert.
systemProxyFailed: systemProxyFailed:
title: Systemproxy title: Systemproxy
body: Der Systemproxy konnte nicht gesetzt werden. Öffnen Sie Clash Verge, um zu sehen, warum. 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. body: Nach mehreren Fehlversuchen in Folge gestoppt. Öffnen Sie Clash Verge, um zu sehen, warum.
tunModeToggled: tunModeToggled:
title: TUN-Modus title: TUN-Modus
'on': TUN mode has been enabled. 'on': Der TUN-Modus wurde aktiviert.
'off': TUN mode has been disabled. 'off': Der TUN-Modus wurde deaktiviert.
lightweightModeEntered: lightweightModeEntered:
title: Leichtmodus title: Leichtmodus
body: Leichtmodus aktiviert. body: Leichtmodus aktiviert.
@@ -30,13 +30,15 @@ notifications:
title: Beenden steht bevor title: Beenden steht bevor
body: Clash Verge wird gleich beendet. body: Clash Verge wird gleich beendet.
updateReady: updateReady:
title: Clash Verge Update title: Clash-Verge-Update
body: A new version (v{version}) has been downloaded and is ready to install. body: Eine neue Version (v{version}) wurde heruntergeladen und kann installiert werden.
installNow: Install Now installNow: Jetzt installieren
later: Later later: Später
service: service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich. adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation 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: launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}' 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}' homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profile profiles: Profile
proxies: Proxy proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy systemProxy: Systemproxy
tunMode: TUN-Modus tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen closeAllConnections: Alle Verbindungen schließen
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges. adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling 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: launchGuard:
currentExeError: 'Unable to determine the application location: {error}' currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}' homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profiles profiles: Profiles
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy systemProxy: System Proxy
tunMode: TUN Mode tunMode: TUN Mode
closeAllConnections: Close All Connections closeAllConnections: Close All Connections
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador. adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar 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: launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}' 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}' 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 global: Global
profiles: Perfiles profiles: Perfiles
proxies: Proxies proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema systemProxy: Proxy del sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones closeAllConnections: Cerrar todas las conexiones
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد. adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد. adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
core:
alreadyRunning: 'هسته شروع نمی‌شود: یک فرایند هسته از قبل در حال اجراست. برنامه مرتبط را ببندید یا فرایند باقی‌مانده را متوقف کنید و دوباره تلاش کنید. اگر مشکل ادامه داشت، رایانه را راه‌اندازی مجدد کنید.'
launchGuard: launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}' currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}' homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
@@ -68,6 +70,8 @@ tray:
global: سراسری global: سراسری
profiles: پروفایل‌ها profiles: پروفایل‌ها
proxies: پروکسی‌ها proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم systemProxy: پروکسی سیستم
tunMode: حالت TUN tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها closeAllConnections: بستن همه اتصال‌ها
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator. adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi 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: launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}' currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}' homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
@@ -68,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profil profiles: Profil
proxies: Proksi proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem systemProxy: Proksi Sistem
tunMode: Mode TUN tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi closeAllConnections: Tutup Semua Koneksi
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。 adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。 adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
core:
alreadyRunning: コアを起動できません:コアプロセスが実行中です。関連アプリまたは残存プロセスを終了して再試行してください。解決しない場合は、コンピューターを再起動してください。
launchGuard: launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error} currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error} homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
@@ -68,6 +70,8 @@ tray:
global: グローバル global: グローバル
profiles: プロファイル profiles: プロファイル
proxies: プロキシ proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ systemProxy: システムプロキシ
tunMode: TUN モード tunMode: TUN モード
closeAllConnections: すべての接続を閉じる closeAllConnections: すべての接続を閉じる
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다. adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다. adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
core:
alreadyRunning: 코어를 시작할 수 없습니다. 이미 실행 중인 코어 프로세스가 있습니다. 관련 앱이나 남아 있는 프로세스를 종료한 뒤 다시 시도하세요. 문제가 계속되면 컴퓨터를 다시 시작하세요.
launchGuard: launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}' currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}' homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
@@ -68,6 +70,8 @@ tray:
global: 글로벌 global: 글로벌
profiles: 프로필 profiles: 프로필
proxies: 프록시 proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시 systemProxy: 시스템 프록시
tunMode: TUN 모드 tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기 closeAllConnections: 모든 연결 닫기
+94
View File
@@ -0,0 +1,94 @@
_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
testLatency: Test Latency
testingLatency: Testing latency…
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: service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора. adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора. adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
core:
alreadyRunning: 'Не удалось запустить ядро: процесс ядра уже работает. Закройте соответствующее приложение или завершите оставшийся процесс и повторите попытку. Если проблема сохраняется, перезагрузите компьютер.'
launchGuard: launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}' currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}' homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
@@ -68,6 +70,8 @@ tray:
global: Глобальный global: Глобальный
profiles: Профили profiles: Профили
proxies: Прокси proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси systemProxy: Системный прокси
tunMode: Режим TUN tunMode: Режим TUN
closeAllConnections: Закрыть все соединения closeAllConnections: Закрыть все соединения
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir. 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. 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: launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}' currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}' homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
@@ -68,6 +70,8 @@ tray:
global: Küresel global: Küresel
profiles: Profiller profiles: Profiller
proxies: Vekil'ler proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i systemProxy: Sistem Vekil'i
tunMode: TUN Modu tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat closeAllConnections: Tüm Bağlantıları Kapat
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк. adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк. adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
core:
alreadyRunning: 'Төшне башлап булмый: төш процессы инде эшли. Тиешле кушымтаны ябыгыз яки калган процессны тәмамлагыз һәм яңадан сынап карагыз. Проблема хәл ителмәсә, санакны яңадан кабызыгыз.'
launchGuard: launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}' currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}' homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
@@ -68,6 +70,8 @@ tray:
global: Глобаль global: Глобаль
profiles: Профильләр profiles: Профильләр
proxies: Проксилар proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси systemProxy: Системалы прокси
tunMode: TUN режимы tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу closeAllConnections: Барлык тоташуларны ябу
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限 adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限 adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
core:
alreadyRunning: 无法启动内核:已有内核进程正在运行。请退出相关应用或清理残留进程后重试。若仍无法解决,请重启电脑。
launchGuard: launchGuard:
currentExeError: 无法确定应用位置:{error} currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error} homeDirError: 无法确定当前用户主目录,应用将退出:{error}
@@ -68,6 +70,8 @@ tray:
global: 全局 global: 全局
profiles: 订阅 profiles: 订阅
proxies: 代理 proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理 systemProxy: 系统代理
tunMode: TUN 模式 tunMode: TUN 模式
closeAllConnections: 关闭所有连接 closeAllConnections: 关闭所有连接
+4
View File
@@ -37,6 +37,8 @@ notifications:
service: service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限 adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限 adminUninstallPrompt: 解除安裝 Clash Verge 服務需要管理員權限
core:
alreadyRunning: 無法啟動核心:已有核心程序正在執行。請結束相關應用程式或清理殘留程序後重試。若仍無法解決,請重新啟動電腦。
launchGuard: launchGuard:
currentExeError: 無法判斷應用程式位置:{error} currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error} homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
@@ -68,6 +70,8 @@ tray:
global: 全域 global: 全域
profiles: 訂閱 profiles: 訂閱
proxies: 代理 proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理 systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式 tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線 closeAllConnections: 關閉所有連線
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true } log = { workspace = true }
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::utils::{country_code_to_emoji, get_local_date_string}; use super::utils::{country_code_to_emoji, get_local_date_string};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct UnlockItem { pub struct UnlockItem {
pub name: String, pub name: String,
pub status: String, pub status: String,
+23 -31
View File
@@ -1,3 +1,15 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{ use std::sync::{
OnceLock, OnceLock,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -5,20 +17,13 @@ use std::sync::{
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix; mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new(); pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)] /// Serializes signal-triggered cleanups: a signal arriving while a quit
pub enum ShutdownOutcome { /// attempt is still in flight is dropped instead of overlapping it.
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch { pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool, is_cleaning_up: AtomicBool,
} }
@@ -36,17 +41,18 @@ impl ShutdownLatch {
.is_ok() .is_ok()
} }
fn finish(&self, outcome: ShutdownOutcome) { fn finish(&self) {
if matches!(outcome, ShutdownOutcome::Canceled) { self.is_cleaning_up.store(false, Ordering::Release);
self.is_cleaning_up.store(false, Ordering::Release);
}
} }
} }
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static, Fut: Future<Output = ()> + Send + 'static,
{ {
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt), Ok(rt) => Some(rt),
@@ -61,36 +67,22 @@ where
} }
}); });
#[cfg(unix)]
unix::register(f); unix::register(f);
#[cfg(windows)]
windows::register(f);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ShutdownLatch, ShutdownOutcome}; use super::ShutdownLatch;
#[test] #[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() { fn finished_attempt_releases_latch_for_the_next_signal() {
let latch = ShutdownLatch::new(); let latch = ShutdownLatch::new();
assert!(latch.try_begin()); assert!(latch.try_begin());
assert!(!latch.try_begin()); assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled); latch.finish();
assert!(latch.try_begin()); assert!(latch.try_begin());
} }
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
} }
+4 -3
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome}; use crate::{RUNTIME, SHUTDOWN_LATCH};
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static, Fut: Future<Output = ()> + Send + 'static,
{ {
if let Some(Some(rt)) = RUNTIME.get() { if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move { rt.spawn(async move {
@@ -60,7 +60,8 @@ where
} }
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
SHUTDOWN_LATCH.finish(f().await); f().await;
SHUTDOWN_LATCH.finish();
} }
}); });
} else { } else {
-83
View File
@@ -1,83 +0,0 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -5,6 +5,7 @@ edition = "2024"
rust-version = "1.91" rust-version = "1.91"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
@@ -14,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] } sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
libc = "0.2.189" libc = "0.2.191"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
@@ -1,53 +1,46 @@
use parking_lot::RwLock; use parking_lot::RwLock;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Runtime, State, command}; use tauri::{AppHandle, Runtime, State, command};
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error}; use tauri_plugin_clipboard_manager::ClipboardExt as _;
use crate::Platform; use crate::Platform;
#[derive(Serialize)] #[derive(Serialize, specta::Type)]
pub struct SystemInfo { pub struct SystemInfo {
pub system_name: String, pub system_name: String,
pub system_version: String, pub system_version: String,
pub system_kernel_version: String,
pub system_arch: String,
pub app_version: String,
pub app_core_mode: String,
pub app_is_admin: bool,
} }
impl From<Platform> for SystemInfo { impl From<&Platform> for SystemInfo {
fn from(platform: Platform) -> Self { fn from(platform: &Platform) -> Self {
Self { Self {
system_name: platform.sysinfo.system_name, system_name: platform.sysinfo.system_name.clone(),
system_version: platform.sysinfo.system_version, system_version: platform.sysinfo.system_version.clone(),
system_kernel_version: platform.sysinfo.system_kernel_version,
system_arch: platform.sysinfo.system_arch,
app_version: platform.appinfo.app_version,
app_core_mode: platform.appinfo.app_core_mode,
app_is_admin: platform.appinfo.app_is_admin,
} }
} }
} }
#[command] #[command]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> { #[specta::specta]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
let platform = state.inner().read(); let platform = state.inner().read();
Ok(SystemInfo::from(platform.clone())) Ok(SystemInfo::from(&*platform))
} }
/// 获取应用的运行时间(毫秒) /// 获取应用的运行时间(毫秒)
#[command] #[command]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> { #[specta::specta]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis()) Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
} }
#[command] #[command]
#[specta::specta]
pub fn export_diagnostic_info<R: Runtime>( pub fn export_diagnostic_info<R: Runtime>(
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
state: State<'_, RwLock<Platform>>, state: State<'_, RwLock<Platform>>,
) -> Result<(), Error> { ) -> Result<(), String> {
let info = state.inner().read().to_string(); let info = state.inner().read().to_string();
let clipboard = app_handle.clipboard(); let clipboard = app_handle.clipboard();
clipboard.write_text(info) clipboard.write_text(info).map_err(|error| error.to_string())
} }
+89
View File
@@ -1,3 +1,92 @@
## v2.5.8
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统托盘代理组行不显示延迟、超时项与未测过项无法区分的问题
- 修复不同订阅源的同名节点测速和延迟显示指向错误节点的问题
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复已有内核进程阻止启动时缺少多语言说明和处理建议的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
- 修复关闭「自动检查更新」后,启动时仍弹出已下载更新的安装提示的问题
- 修复从 Dock 退出、注销或关机时,应用可能长时间卡住无法退出的问题
- 修复宽窗口下「订阅」页每行显示过多卡片,导致配置名称被截断的问题
**🖥️ Windows**
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
- 修复 Windows 长时间运行后,托盘菜单点击无响应的问题
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
**🖥️/🍎 Windows/macOS**
- 修复 Windows/macOS 打开系统托盘菜单时,节点延迟与自动选择状态未刷新的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增系统托盘代理组中的「测试延迟」菜单项
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
## 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 ## v2.5.6
<details> <details>
+4 -3
View File
@@ -11,13 +11,14 @@ A Clash Meta GUI built with <a href="https://github.com/tauri-apps/tauri">Tauri<
<p align="center"> <p align="center">
Languages: Languages:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## Preview ## Preview
+4 -3
View File
@@ -11,13 +11,14 @@ Una interfaz gráfica para Clash Meta construida con <a href="https://github.com
<p align="center"> <p align="center">
Idiomas: Idiomas:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## Vista previa ## Vista previa
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center"> <p align="center">
زبان‌ها: زبان‌ها:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## پیش‌نمایش ## پیش‌نمایش
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center"> <p align="center">
言語: 言語:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## プレビュー ## プレビュー
+4 -3
View File
@@ -11,13 +11,14 @@
<p align="center"> <p align="center">
언어: 언어:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## 미리보기 ## 미리보기
+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.
+4 -3
View File
@@ -11,13 +11,14 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
<p align="center"> <p align="center">
Языки: Языки:
<a href="../README.md">简体中文</a> ·
<a href="./README_en.md">English</a> · <a href="./README_en.md">English</a> ·
<a href="./README_es.md">Español</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_ja.md">日本語</a> ·
<a href="./README_ko.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> </p>
## Предпросмотр ## Предпросмотр
+115
View File
@@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals' import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
const ipcBoundaryRules = ({
allowCommands = false,
allowInvoke = false,
allowEvents = false,
} = {}) => {
const paths = [
...(!allowInvoke
? [
{
name: '@tauri-apps/api/core',
importNames: ['invoke'],
allowTypeImports: true,
message: 'Invoke backend commands through services/cmds.',
},
]
: []),
...(!allowEvents
? [
{
name: '@tauri-apps/api/event',
importNames: ['listen', 'emit', 'emitTo', 'once'],
allowTypeImports: true,
message:
'The event bus owns Tauri event subscriptions and emissions.',
},
]
: []),
]
const patterns = allowCommands
? []
: [
{
regex: '(^|/)bindings(\\.ts)?$',
importNames: ['commands'],
allowTypeImports: true,
message:
'Only services/cmds may import generated command implementations.',
},
]
const modules = [
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
]
const syntax = modules.flatMap((name) => [
{
selector: `ImportExpression[source.value='${name}']`,
message:
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
},
{
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
message:
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
},
{
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
message: 'Use the owned IPC service boundary for Tauri APIs.',
},
])
if (!allowCommands) {
syntax.push(
{
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
)
}
return {
'no-restricted-imports': ['error', { paths, patterns }],
'no-restricted-syntax': ['error', ...syntax],
}
}
export default defineConfig([ export default defineConfig([
pluginESx.configs['flat/restrict-to-es2022'], pluginESx.configs['flat/restrict-to-es2022'],
{ {
@@ -162,4 +248,33 @@ export default defineConfig([
}, },
}, },
}, },
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
},
{
files: ['src/services/cmds.ts'],
rules: ipcBoundaryRules({ allowCommands: true }),
},
{
files: ['src/services/bindings.ts'],
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
},
{
files: ['src/services/bus/**/*.{ts,tsx}'],
rules: ipcBoundaryRules({ allowEvents: true }),
},
{
files: [
'src/components/proxy/proxy-chain.tsx',
'src/components/proxy/proxy-groups-chain.tsx',
'src/pages/proxies.tsx',
],
rules: {
'@typescript-eslint/no-floating-promises': [
'error',
{ ignoreVoid: false },
],
},
},
]) ])
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://unpkg.com/knip@6/schema.json", "$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!"], "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [ "include": [
"files", "files",
@@ -17,7 +17,8 @@
"ignoreBinaries": ["rustc", "cc"], "ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"], "ignoreDependencies": ["lint-staged"],
"ignoreIssues": { "ignoreIssues": {
"src/types/generated/**": ["exports", "types"] "src/types/generated/**": ["exports", "types"],
"src/services/bindings.ts": ["types"]
}, },
"cycles": { "cycles": {
"dynamicImports": true "dynamicImports": true
+20 -18
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.5.7", "version": "2.5.9",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"prepare": "husky || true", "prepare": "husky || true",
@@ -30,6 +30,8 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs", "i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply", "i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs", "i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -52,13 +54,13 @@
"@mui/icons-material": "^9.4.0", "@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0", "@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13", "@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.11.1", "@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.3", "@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "^2.7.3", "@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "^2.5.2", "@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "~2.6.1", "@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.5.5", "@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.12.0", "@tauri-apps/plugin-updater": "2.13.1",
"ahooks": "^3.10.0", "ahooks": "^3.10.0",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
@@ -69,15 +71,15 @@
"js-yaml": "^5.4.2", "js-yaml": "^5.4.2",
"lodash-es": "^4.18.1", "lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31", "meta-json-schema": "^1.19.31",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.57.0",
"monaco-yaml": "^5.5.1", "monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.3.0", "react": "19.3.1",
"react-dom": "19.3.0", "react-dom": "19.3.1",
"react-error-boundary": "6.1.5", "react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-i18next": "17.0.14", "react-i18next": "17.0.19",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
@@ -93,7 +95,7 @@
"@biomejs/biome": "^2.5.14", "@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5", "@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.11.4", "@tauri-apps/cli": "2.12.1",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6", "@types/node": "^24.13.6",
"@types/react": "19.3.0", "@types/react": "19.3.0",
@@ -121,7 +123,7 @@
"https-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"knip": "6.35.1", "knip": "6.41.0",
"lint-staged": "^17.5.1", "lint-staged": "^17.5.1",
"sass": "^1.104.1", "sass": "^1.104.1",
"tar": "^7.5.22", "tar": "^7.5.22",
@@ -131,8 +133,8 @@
"vite": "^8.3.0", "vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"webdriverio": "9.31.9", "webdriverio": "10.0.2",
"ws": "8.21.3" "ws": "8.22.0"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,mjs}": [ "*.{ts,tsx,js,mjs}": [
@@ -144,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.8.1" "packageManager": "pnpm@12.14.0"
} }
+634 -840
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -45,6 +45,17 @@
"matchManagers": ["npm"], "matchManagers": ["npm"],
"groupName": "npm dependencies" "groupName": "npm dependencies"
}, },
{
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
"matchPackageNames": [
"@tauri-apps/**",
"@wdio/tauri-service",
"tauri",
"tauri-*"
],
"groupName": "tauri",
"addLabels": ["tauri"]
},
{ {
"description": "Group all GitHub Actions updates into a single PR", "description": "Group all GitHub Actions updates into a single PR",
"matchManagers": ["github-actions"], "matchManagers": ["github-actions"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.98.1" channel = "1.99.1"
components = ["rustfmt", "clippy"] components = ["rustfmt", "clippy"]
+34
View File
@@ -0,0 +1,34 @@
import path from 'node:path'
import { ESLint } from 'eslint'
const ROOT = path.resolve(import.meta.dirname, '..')
const gates = process.argv.slice(2)
if (
!gates.length ||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
const results = await eslint.lintFiles(['src'])
const boundaryRules = new Set([
'no-restricted-imports',
'no-restricted-syntax',
'@typescript-eslint/no-floating-promises',
])
const violations = results.flatMap((result) =>
result.messages
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
.map((message) => ({ file: result.filePath, ...message })),
)
for (const violation of violations) {
console.error(
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
)
}
if (violations.length) process.exitCode = 1
else console.log('[arch] no violations')
+69
View File
@@ -0,0 +1,69 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const git = (...args) =>
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
const { contract } = JSON.parse(
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
)
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
const args = process.argv.slice(2)
if (args.length !== 2 || args[0] !== '--push') {
console.error('usage: check-contract-changes.mjs --push <remote>')
process.exit(2)
}
const hasChanges = (...range) =>
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
const assertCleanInputs = (...range) => {
if (
hasChanges(...range) ||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
) {
throw new Error(
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
)
}
}
let relevant = false
const head = git('rev-parse', 'HEAD')
for (const line of fs
.readFileSync(0, 'utf8')
.trim()
.split('\n')
.filter(Boolean)) {
const fields = line.trim().split(/\s+/)
if (
fields.length !== 4 ||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
) {
throw new Error('Invalid pre-push ref update')
}
const [, local, , remote] = fields
if (/^0+$/.test(local) || local === remote) continue
// A new remote branch has no upstream; use its fork point with the remote default branch.
const base = /^0+$/.test(remote)
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
: remote
if (!hasChanges(base, local)) continue
const commit = git('rev-parse', `${local}^{commit}`)
if (commit !== head)
throw new Error(
'Check out the pushed commit before validating its changed IPC contract.',
)
assertCleanInputs('HEAD')
relevant = true
}
if (relevant) {
execFileSync(
process.execPath,
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
{
cwd: ROOT,
stdio: 'inherit',
},
)
} else {
console.log('[contract] inputs unchanged; skipping Rust generation')
}
+23
View File
@@ -0,0 +1,23 @@
{
"contract": [
"src-tauri/**/*.rs",
"crates/**/*.rs",
"Cargo.toml",
"Cargo.lock",
"src-tauri/Cargo.toml",
"crates/**/Cargo.toml",
"rust-toolchain.toml",
"src-tauri/tauri.conf*.json",
"src/services/bindings.ts",
"scripts/gen-contract.mjs",
"scripts/check-contract-changes.mjs",
"biome.json",
"biome.jsonc",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/frontend-check.yml",
".husky/pre-push"
]
}
+55
View File
@@ -0,0 +1,55 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
const args = process.argv.slice(2)
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
console.error('usage: gen-contract.mjs [--check]')
process.exit(2)
}
const check = args[0] === '--check'
const temporary = check
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
: null
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
try {
execFileSync(
'cargo',
[
'run',
'-p',
'clash-verge',
'--example',
'frontend-contract',
'--locked',
'-q',
...(check ? ['--', output] : []),
],
{ cwd: ROOT, stdio: 'inherit' },
)
const formatted = execFileSync(
process.execPath,
[
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
'format',
'--stdin-file-path',
BINDINGS,
],
{ cwd: ROOT, input: fs.readFileSync(output) },
)
if (check) {
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
console.error(
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
)
process.exitCode = 1
}
} else {
fs.writeFileSync(BINDINGS, formatted)
}
} finally {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
}
+41 -19
View File
@@ -30,29 +30,51 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1" 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 [ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid 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}') # Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" ' service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)} hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}} [ -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 backup=".original_dns/$service_id"
for ip in $original_dns; do mkdir -p .original_dns || exit 1
ip=$(echo "$ip" | tr -d '[:space:]') # Older releases kept a single backup for whichever service was primary.
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then if [ -f .original_dns.txt ]; then
is_valid_dns=true if [ -f "$backup" ]; then
break rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
fi fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi 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 #!/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" ' # Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)} function sc_value() {
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}} 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 if [ -f .original_dns.txt ]; then
original_dns=$(cat .original_dns.txt) service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
networksetup -setdnsservers "$hardware_port" $original_dns if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
rm -rf .original_dns.txt 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 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.
+32 -26
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.5.7" version = "2.5.9"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
default-run = "clash-verge" default-run = "clash-verge"
build = "build.rs" build = "build.rs"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.99"
[lib] [lib]
name = "app_lib" name = "app_lib"
@@ -29,13 +29,12 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.6.3", features = [] } tauri-build = { version = "2.7.1", features = [] }
[dependencies] [dependencies]
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true } clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true } clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true } clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -66,7 +65,6 @@ dunce = "1.0.5"
nanoid = "0.5" nanoid = "0.5"
chrono = { workspace = true } chrono = { workspace = true }
boa_engine = "0.22.0" boa_engine = "0.22.0"
once_cell = { version = "1.21.4", features = ["parking_lot"] }
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
reqwest = { workspace = true } reqwest = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
@@ -74,48 +72,56 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard", "guard",
] } ] }
network-interface = { version = "2.0.5", features = ["serde"] } network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.6" tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.5.5" tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.7.3" tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.5.2" tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.3.1" tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.4.10" tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.4.1" tauri-plugin-window-state = "2.5.0"
zip = "8.6.0" zip = "9.0.4"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
base64 = "0.23.1" base64 = "0.23.1"
sha2 = "0.11" minisign-verify = "0.3.0"
sha2 = "0.11.1"
getrandom = "0.4.3" getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
gethostname = "1.1.0" gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.4.0" tauri-plugin-notification = "2.5.1"
tokio-stream = "0.1.19" tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] } backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.6.1" tauri-plugin-http = "2.8.0"
tracing = { workspace = true } 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" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.2"
tokio-rustls = "0.26.5" tokio-rustls = "0.26.6"
rustls = { version = "0.23.45", features = ["ring"] } rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.9" webpki-roots = "1.0.9"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] } tokio-util = { version = "0.7.20", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.189" libc = "0.2.191"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true } 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 = [ objc2-foundation = { version = "0.3.2", features = [
"NSString", "NSString",
"NSDictionary", "NSDictionary",
@@ -146,9 +152,9 @@ windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1" tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.3.2" tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.12.0" tauri-plugin-updater = "2.13.1"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } criterion = { workspace = true }
+14
View File
@@ -0,0 +1,14 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1);
let output = args
.next()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
if args.next().is_some() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
}
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
Ok(())
}
+1
View File
@@ -901,6 +901,7 @@ Section Install
nsExec::Exec 'netsh int tcp res' nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses !insertmacro CheckAllVergeProcesses
+49 -7
View File
@@ -1,15 +1,22 @@
use super::CmdResult; 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 smartstring::alias::String;
use tauri::{AppHandle, Manager as _}; use tauri::{AppHandle, Manager as _, ipc::Channel};
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_app_dir() -> CmdResult<()> { pub async fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir().stringify_err()?; let app_dir = dirs::app_home_dir().stringify_err()?;
open::that(app_dir).stringify_err() open::that(app_dir).stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_core_dir() -> CmdResult<()> { pub async fn open_core_dir() -> CmdResult<()> {
let core_dir = tauri::utils::platform::current_exe().stringify_err()?; let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?; let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
@@ -17,12 +24,14 @@ pub async fn open_core_dir() -> CmdResult<()> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn open_logs_dir() -> CmdResult<()> { pub async fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir().stringify_err()?; let log_dir = dirs::app_logs_dir().stringify_err()?;
open::that(log_dir).stringify_err() open::that(log_dir).stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn open_devtools(app_handle: AppHandle) { pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() { if !window.is_devtools_open() {
@@ -34,28 +43,61 @@ pub fn open_devtools(app_handle: AppHandle) {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn exit_app() { pub async fn exit_app() {
feat::quit().await; feat::quit().await;
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn restart_app() -> CmdResult<()> { pub async fn restart_app() -> CmdResult<()> {
feat::restart_app().await; feat::restart_app().await;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub fn get_app_dir() -> CmdResult<String> { #[specta::specta]
pub async fn install_update(
app_handle: AppHandle,
version: std::string::String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
let version: String = version.into();
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
})
.await
.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn cancel_update_download() {
SilentUpdater::global().cancel_download();
}
#[tauri::command]
#[specta::specta]
pub fn get_app_dir() -> CmdResult<std::string::String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir) Ok(app_home_dir)
} }
#[tauri::command] #[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> { #[specta::specta]
feat::download_icon_cache(url, name).await pub async fn download_icon_cache(
url: std::string::String,
name: std::string::String,
) -> CmdResult<std::string::String> {
let url: String = url.into();
let name: String = name.into();
feat::download_icon_cache(url, name).await.map(Into::into)
} }
#[tauri::command] #[tauri::command]
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> { #[specta::specta]
feat::copy_icon_file(path, icon_info).await pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
let path: String = path.into();
feat::copy_icon_file(path, icon_info).await.map(Into::into)
} }
+16 -5
View File
@@ -4,33 +4,44 @@ use feat::LocalBackupFile;
use smartstring::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn create_local_backup() -> CmdResult<()> { pub async fn create_local_backup() -> CmdResult<()> {
feat::create_local_backup().await.stringify_err() feat::create_local_backup().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> { pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
feat::list_local_backup().await.stringify_err() feat::list_local_backup().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn delete_local_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_local_backup(filename).await.stringify_err() feat::delete_local_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_local_backup(filename) feat::restore_local_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
} }
#[tauri::command] #[tauri::command]
pub async fn import_local_backup(source: String) -> CmdResult<String> { #[specta::specta]
feat::import_local_backup(source).await.stringify_err() pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
let source: String = source.into();
feat::import_local_backup(source).await.map(Into::into).stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { #[specta::specta]
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
let destination: String = destination.into();
feat::export_local_backup(filename, destination).await.stringify_err() feat::export_local_backup(filename, destination).await.stringify_err()
} }
+40 -27
View File
@@ -1,4 +1,5 @@
use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error}; use super::{CmdResult, CommandFailure, WithErrorCode as _, proxy_aware_coded_error, proxy_aware_error};
use crate::core::notify::NoticeStatus;
use crate::feat; use crate::feat;
use crate::utils::{dirs, yaml_emitter}; use crate::utils::{dirs, yaml_emitter};
use crate::{ use crate::{
@@ -11,30 +12,33 @@ use crate::{
}, },
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use tokio::fs; use tokio::fs;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn copy_clash_env() -> CmdResult { pub async fn copy_clash_env() -> CmdResult {
feat::copy_clash_env().await; feat::copy_clash_env().await.stringify_err()
Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_clash_info() -> CmdResult<ClashInfo> { pub async fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().await.data_arc().get_client_info()) Ok(Config::clash().await.data_arc().get_client_info())
} }
#[tauri::command] #[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult { #[specta::specta]
feat::patch_clash(&payload) pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
feat::patch_clash(&payload.0)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
} }
#[tauri::command] #[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult { #[specta::specta]
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
let payload: String = payload.into();
feat::change_clash_mode(payload) feat::change_clash_mode(payload)
.await .await
.with_error_code("CLASH_MODE_UPDATE_FAILED") .with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -42,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization. /// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command] #[tauri::command]
pub async fn get_clash_mode() -> CmdResult<Option<String>> { #[specta::specta]
Ok(Config::clash().await.data_arc().get_mode().map(Into::into)) pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
Ok(Config::clash().await.data_arc().get_mode())
} }
#[tauri::command] #[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> { #[specta::specta]
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
let clash_core: String = clash_core.into();
logging!(info, Type::Config, "changing core to {clash_core}"); logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await { match CoreManager::global().change_core(&clash_core).await {
@@ -56,14 +63,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
match CoreManager::global().restart_core().await { match CoreManager::global().restart_core().await {
Ok(_) => { Ok(_) => {
handle::Handle::notice_message("config_core::change_success", clash_core); handle::Handle::notice(NoticeStatus::ConfigCoreChangeSuccess, clash_core.as_str());
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
Ok(None) Ok(None)
} }
Err(err) => { Err(err) => {
let failed = err.context("core changed but failed to restart"); let failed = err.context("core changed but failed to restart");
let error_msg: String = format!("{failed:#}").into(); 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}"); logging!(error, Type::Core, "core changed but failed to restart: {error_msg}");
Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED"))) Ok(Some(proxy_aware_coded_error(&failed, "CORE_CHANGE_FAILED")))
} }
@@ -72,13 +79,14 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
Err(err) => { Err(err) => {
let error_msg: String = format!("{err:#}").into(); let error_msg: String = format!("{err:#}").into();
logging!(error, Type::Core, "failed to change core: {error_msg}"); 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"))) Ok(Some(proxy_aware_coded_error(&err, "CORE_CHANGE_FAILED")))
} }
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn restart_core() -> CmdResult { pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await); logging_error!(Type::Core, profiles_save_file_safe().await);
let result = CoreManager::global() let result = CoreManager::global()
@@ -93,6 +101,7 @@ pub async fn restart_core() -> CmdResult {
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself. /// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> { pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?; let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
if report.upgraded { if report.upgraded {
@@ -102,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
} }
#[tauri::command] #[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> { #[specta::specta]
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
let url: String = url.into();
let result = match feat::test_delay(url).await { let result = match feat::test_delay(url).await {
Ok(delay) => delay, Ok(delay) => delay,
Err(e) => { Err(e) => {
@@ -114,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
} }
#[tauri::command] #[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { #[specta::specta]
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
use crate::utils::dirs; use crate::utils::dirs;
use tokio::fs; use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?; let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?; fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}"); logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
@@ -128,22 +140,27 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_dns_override_notice() -> bool { pub fn take_dns_override_notice() -> bool {
crate::config::dns::take_dns_override_notice() crate::config::dns::take_dns_override_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn set_dns_override( pub async fn set_dns_override(
profile_uid: String, profile_uid: std::string::String,
enabled: bool, enabled: bool,
confirmation: Option<String>, confirmation: Option<std::string::String>,
) -> CmdResult<feat::DnsOverrideOutcome> { ) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid.into();
let confirmation = confirmation.map(String::from);
feat::set_dns_override(profile_uid, enabled, confirmation) feat::set_dns_override(profile_uid, enabled, confirmation)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn apply_dns_config(apply: bool) -> CmdResult { pub async fn apply_dns_config(apply: bool) -> CmdResult {
if apply { if apply {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
@@ -162,7 +179,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
); );
})?; })?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| { serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!( logging!(
error, error,
Type::Config, Type::Config,
@@ -171,13 +188,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
); );
})?; })?;
let mut patch = serde_yaml_ng::Mapping::new();
patch.insert("dns".into(), patch_config.into());
Config::runtime().await.edit_draft(|d| {
d.patch_config(&patch);
});
CoreManager::global().update_config_checked().await.map_err(|error| { CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}"); logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error) proxy_aware_error(&error)
@@ -201,20 +211,22 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn get_dns_config_content() -> CmdResult<Option<String>> { #[specta::specta]
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
use crate::utils::dirs; use crate::utils::dirs;
use tokio::fs; use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
match fs::read_to_string(&dns_path).await { match fs::read_to_string(&dns_path).await {
Ok(content) => Ok(Some(content.into())), Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(CommandFailure::plain(error)), Err(error) => Err(CommandFailure::plain(error)),
} }
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> { pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
let app_dir = dirs::app_home_dir().stringify_err()?; let app_dir = dirs::app_home_dir().stringify_err()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG); let dns_path = app_dir.join(constants::files::DNS_CONFIG);
@@ -231,6 +243,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> { pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default(); let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs) Ok(logs)
+2 -1
View File
@@ -1,8 +1,9 @@
use crate::module::lightweight; use crate::core::lightweight;
use super::CmdResult; use super::CmdResult;
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn entry_lightweight_mode() -> CmdResult { pub async fn entry_lightweight_mode() -> CmdResult {
lightweight::entry_lightweight_mode().await; lightweight::entry_lightweight_mode().await;
Ok(()) Ok(())
+2
View File
@@ -5,11 +5,13 @@ use crate::{
}; };
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> { pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err() feat::probe_listener(request).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> { pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings) feat::save_proxy_ports(settings)
.await .await
@@ -4,6 +4,7 @@ use std::time::Duration;
use tauri::{command, ipc::Channel}; use tauri::{command, ipc::Channel};
#[command] #[command]
#[specta::specta]
pub fn get_unlock_items() -> Vec<UnlockItem> { pub fn get_unlock_items() -> Vec<UnlockItem> {
clash_verge_media_unlock::default_unlock_items() clash_verge_media_unlock::default_unlock_items()
} }
@@ -26,6 +27,7 @@ async fn create_client() -> Result<Client, String> {
} }
#[command] #[command]
#[specta::specta]
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> { pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
let client = create_client().await?; let client = create_client().await?;
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| { Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
@@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
} }
#[command] #[command]
#[specta::specta]
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> { pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
let client = create_client().await?; let client = create_client().await?;
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
+1 -1
View File
@@ -4,7 +4,7 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>; pub type CmdResult<T = ()> = Result<T, CommandFailure>;
/// Structured command failure returned to the frontend. /// Structured command failure returned to the frontend.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
pub struct CommandFailure { pub struct CommandFailure {
/// Stable frontend identifier, when classified. /// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
+34 -20
View File
@@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _;
use crate::core::{proxy_control, sysopt::Sysopt}; use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname; use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use sysproxy::{Autoproxy, Sysproxy}; use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo; use tauri_plugin_clash_verge_sysinfo;
#[tauri::command] #[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> { #[specta::specta]
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
Sysopt::global().wait_idle().await; Sysopt::global().wait_idle().await;
// With no network service there is no proxy configured anywhere, which reads as disabled. // With no network service there is no proxy configured anywhere, which reads as disabled.
let sys_proxy = match Sysproxy::get_system_proxy() { let sys_proxy = match Sysproxy::get_system_proxy() {
@@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
other => other.stringify_err()?, other => other.stringify_err()?,
}; };
let Sysproxy { let Sysproxy {
ref host, host,
ref bypass, bypass,
ref port, port,
ref enable, enable,
} = sys_proxy; } = sys_proxy;
let mut map = Mapping::new(); Ok(SystemProxy {
map.insert("enable".into(), (*enable).into()); enable,
map.insert("server".into(), format!("{}:{}", host, port).into()); server: format!("{host}:{port}"),
map.insert("bypass".into(), bypass.as_str().into()); bypass,
Ok(map) })
} }
#[tauri::command] #[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> { #[specta::specta]
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
Sysopt::global().wait_idle().await; Sysopt::global().wait_idle().await;
let auto_proxy = match Autoproxy::get_auto_proxy() { let auto_proxy = match Autoproxy::get_auto_proxy() {
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(), Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
other => other.stringify_err()?, other => other.stringify_err()?,
}; };
let Autoproxy { ref enable, ref url } = auto_proxy; let Autoproxy { enable, url } = auto_proxy;
let mut map = Mapping::new(); Ok(AutoProxy { enable, url })
map.insert("enable".into(), (*enable).into());
map.insert("url".into(), url.as_str().into());
Ok(map)
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_embedded_server_port() -> CmdResult<u16> { pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err() crate::utils::server::embedded_server_port().stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_system_hostname() -> String { pub fn get_system_hostname() -> String {
match gethostname().into_string() { match gethostname().into_string() {
Ok(name) => name, Ok(name) => name,
@@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_network_interfaces() -> Vec<String> { pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces() tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
} }
#[tauri::command] #[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> { #[specta::specta]
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
let names = get_network_interfaces(); let names = get_network_interfaces();
@@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
for interface in interfaces { for interface in interfaces {
if names.contains(&interface.name) { if names.contains(&interface.name) {
result.push(interface); result.push(interface.into());
} }
} }
Ok(result) Ok(result)
} }
#[derive(serde::Serialize, specta::Type)]
pub struct SystemProxy {
pub enable: bool,
pub server: String,
pub bypass: String,
}
#[derive(serde::Serialize, specta::Type)]
pub struct AutoProxy {
pub enable: bool,
pub url: String,
}
+122 -22
View File
@@ -1,6 +1,7 @@
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error}; use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice}; use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles; use crate::config::profiles;
use crate::core::notify::NoticeStatus;
use crate::utils::window_manager::WindowManager; use crate::utils::window_manager::WindowManager;
use crate::{ use crate::{
config::{ config::{
@@ -32,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> { pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
let draft = Config::profiles().await; let draft = Config::profiles().await;
let data = draft.data_arc(); let data = draft.data_arc();
@@ -39,6 +41,7 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> { pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
match feat::enhance_profiles().await { match feat::enhance_profiles().await {
Ok(outcome) if outcome.is_valid() => { Ok(outcome) if outcome.is_valid() => {
@@ -64,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command] #[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))] #[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult { pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it, Ok(it) => it,
@@ -93,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
} }
#[tauri::command] #[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { #[specta::specta]
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
let active_id: String = active_id.into();
let over_id: String = over_id.into();
match profiles_reorder_safe(&active_id, &over_id).await { match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => { Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id); logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -113,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult { #[specta::specta]
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
let item: PrfItem = item.into();
let file_data = file_data.map(String::from);
match profiles_append_item_with_filedata_safe(&item, file_data).await { match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => { Ok(_) => {
profiles_save_file_safe() profiles_save_file_safe()
.await .await
.with_error_code("PROFILE_CREATE_FAILED")?; .with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await); logging_error!(Type::Timer, Timer::global().refresh().await);
if let Some(uid) = &item.uid {
handle::Handle::notify_profile_changed(uid);
}
Ok(()) Ok(())
} }
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)), Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
@@ -130,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
} }
#[tauri::command] #[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult { #[specta::specta]
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
let index: String = index.into();
match feat::update_profile(&index, option.as_ref(), true).await { match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => { Err(e) => {
@@ -141,7 +150,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
} }
#[tauri::command] #[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult { #[specta::specta]
pub async fn delete_profile(index: std::string::String) -> CmdResult {
let index: String = index.into();
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
@@ -271,7 +282,9 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome); logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
discard_and_restore(current_profile).await?; discard_and_restore(current_profile).await?;
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置"); if !matches!(outcome, ValidationOutcome::Busy) {
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
}
Ok(outcome) Ok(outcome)
} }
@@ -282,7 +295,7 @@ async fn handle_update_error<E: std::fmt::Display>(
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,); logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
discard_and_restore(current_profile).await?; discard_and_restore(current_profile).await?;
let message: String = e.to_string().into(); 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)) Ok(ValidationOutcome::invalid_from_message(message))
} }
@@ -300,9 +313,6 @@ async fn perform_config_update(
current_value: Option<&String>, current_value: Option<&String>,
current_profile: Option<&String>, current_profile: Option<&String>,
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await; let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result { match update_result {
@@ -312,9 +322,85 @@ async fn perform_config_update(
} }
} }
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileSelection {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>,
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileCreate {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub option: Option<PrfOption>,
}
impl From<ProfileCreate> for PrfItem {
fn from(item: ProfileCreate) -> Self {
Self {
itype: item.itype,
name: item.name,
desc: item.desc,
url: item.url,
option: item.option,
..Self::default()
}
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfilePatch {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
}
impl From<ProfilePatch> for PrfItem {
fn from(patch: ProfilePatch) -> Self {
Self {
itype: patch.itype,
name: patch.name,
desc: patch.desc,
file: patch.file,
url: patch.url,
selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
..Self::default()
}
}
}
#[tauri::command] #[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))] #[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> { #[specta::specta]
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
if CURRENT_SWITCHING_PROFILE if CURRENT_SWITCHING_PROFILE
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err() .is_err()
@@ -322,13 +408,22 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(ValidationOutcome::Busy); return Ok(ValidationOutcome::Busy);
} }
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles {
current: profiles.current,
items: None,
};
let target_profile = profiles.current.as_ref(); let target_profile = profiles.current.as_ref();
let previous_profile = Config::profiles().await.data_arc().current.clone(); let previous_profile = Config::profiles().await.data_arc().current.clone();
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles)); Config::profiles().await.edit_draft(|d| {
d.patch_config(&profiles);
});
perform_config_update(target_profile, previous_profile.as_ref()) perform_config_update(target_profile, previous_profile.as_ref())
.await .await
@@ -336,15 +431,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
} }
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> { pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
let profiles = IProfiles { let profiles = ProfileSelection {
current: Some(profile_index), current: Some(profile_index),
items: None,
}; };
patch_profiles_config(profiles).await patch_profiles_config(profiles).await
} }
#[tauri::command] #[tauri::command]
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { #[specta::specta]
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into();
let index: String = index.into();
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref() && let Some(new_option) = profile.option.as_ref()
@@ -384,7 +481,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn view_profile(index: String) -> CmdResult { #[specta::specta]
pub async fn view_profile(index: std::string::String) -> CmdResult {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
let file = profiles_ref let file = profiles_ref
@@ -408,7 +506,8 @@ pub async fn view_profile(index: String) -> CmdResult {
} }
#[tauri::command] #[tauri::command]
pub async fn read_profile_file(index: String) -> CmdResult<String> { #[specta::specta]
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
let item = { let item = {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
@@ -428,7 +527,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
.join(file.as_str()); .join(file.as_str());
match tokio::fs::try_exists(&path).await { match tokio::fs::try_exists(&path).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return Ok(String::new()), Ok(false) => return Ok(std::string::String::new()),
Err(err) => { Err(err) => {
return Err(coded_error( return Err(coded_error(
"PROFILE_READ_FAILED", "PROFILE_READ_FAILED",
@@ -439,11 +538,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
} }
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?; let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data) Ok(data.into())
} }
#[tauri::command] #[tauri::command]
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> { #[specta::specta]
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
let timer = Timer::global(); let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid).await; let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time) Ok(next_time)
+76 -32
View File
@@ -4,7 +4,7 @@ use crate::{
config::Config, config::Config,
core::{ core::{
handle::Handle, handle::Handle,
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1}, proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray, tray::Tray,
}, },
process::AsyncHandler, process::AsyncHandler,
@@ -12,12 +12,13 @@ use crate::{
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use std::{ use std::{
collections::HashSet, collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
}; };
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites. /// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> { pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node) crate::config::profiles::record_selected_node(&group_name, &node)
.await .await
@@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> { pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name) crate::config::profiles::forget_selected_node(&group_name)
.await .await
@@ -50,24 +52,93 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.collect() .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] #[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> { pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc(); let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo(); let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let proxies = proxies.stringify_err()?; let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput { Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order, runtime_group_order,
group_scopes,
proxies, proxies,
providers: providers.ok(), providers,
})) }))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> { pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
@@ -84,6 +155,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
} }
async fn run_tray_sync_loop() { async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop { loop {
match Tray::global().update_menu().await { match Tray::global().update_menu().await {
Ok(_) => { Ok(_) => {
@@ -109,31 +181,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()]
);
}
}
+41 -36
View File
@@ -1,44 +1,57 @@
use super::CmdResult; use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter}; use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
use anyhow::{Context as _, anyhow}; use anyhow::{Context as _, anyhow};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
#[tauri::command] #[tauri::command]
pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> { #[specta::specta]
Ok(Config::runtime().await.latest_arc().config.clone()) pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>> {
Ok(Config::runtime()
.await
.latest_arc()
.config
.clone()
.map(crate::ipc::YamlMapping))
} }
#[tauri::command] #[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> { #[specta::specta]
pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
let config = runtime.config.as_ref(); let config = runtime.config.as_ref();
config config
.ok_or_else(|| anyhow!("failed to parse config to yaml file")) .ok_or_else(|| anyhow!("failed to parse config to yaml file"))
.and_then(|config| { .and_then(|config| yaml_emitter::to_mihomo_config_string(config).context("failed to convert config to yaml"))
yaml_emitter::to_mihomo_config_string(config)
.context("failed to convert config to yaml")
.map(|s| s.into())
})
.stringify_err() .stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> { #[specta::specta]
Ok(Config::runtime().await.latest_arc().chain_logs.clone()) pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
Ok(RuntimeLogs(Config::runtime().await.latest_arc().chain_logs.clone()))
}
#[derive(serde::Serialize, specta::Type)]
#[serde(transparent)]
pub struct RuntimeLogs(
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
HashMap<String, Vec<(String, String)>>,
);
#[tauri::command]
#[specta::specta]
pub fn take_discarded_keys_notice() -> Option<std::string::String> {
crate::enhance::take_discarded_keys_notice().map(Into::into)
} }
#[tauri::command] #[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> { #[specta::specta]
crate::enhance::take_discarded_keys_notice() pub async fn get_runtime_proxy_chain_config(
} proxy_chain_exit_node: std::string::String,
) -> CmdResult<std::string::String> {
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
@@ -59,7 +72,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
false false
} }
}) { }) {
proxies_chain.push(proxy.to_owned()); proxies_chain.push(proxy);
proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str()); proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str());
} }
@@ -68,18 +81,17 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
.find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name) .find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name)
&& !proxies_chain.is_empty() && !proxies_chain.is_empty()
{ {
proxies_chain.push(entry_proxy.to_owned()); proxies_chain.push(entry_proxy);
} }
proxies_chain.reverse(); proxies_chain.reverse();
let mut config: HashMap<String, Vec<serde_yaml_ng::Value>> = HashMap::new(); let mut config: HashMap<String, Vec<&serde_yaml_ng::Value>> = HashMap::new();
config.insert("proxies".into(), proxies_chain); config.insert("proxies".into(), proxies_chain);
yaml_emitter::to_mihomo_config_string(&config) yaml_emitter::to_mihomo_config_string(&config)
.context("YAML generation failed") .context("YAML generation failed")
.map(|s| s.into())
.stringify_err() .stringify_err()
} else { } else {
Err("failed to get proxies or proxy-groups".into()) Err("failed to get proxies or proxy-groups".into())
@@ -87,20 +99,13 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
} }
#[tauri::command] #[tauri::command]
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> { #[specta::specta]
match CoreManager::global() pub async fn update_proxy_chain_config_in_runtime(
proxy_chain_config: Option<crate::ipc::YamlValue>,
) -> CmdResult<crate::core::validate::ValidationOutcome> {
let proxy_chain_config = proxy_chain_config.map(|value| value.0);
CoreManager::global()
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config)) .update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
.await .await
{ .stringify_err()
Ok(outcome) if outcome.is_valid() => {}
Ok(outcome) => logging!(
warn,
Type::Core,
"Failed to apply runtime proxy chain config: {}",
outcome
),
Err(err) => logging!(error, Type::Core, "Failed to apply runtime proxy chain config: {err:#}"),
}
Ok(())
} }
+8 -2
View File
@@ -3,11 +3,11 @@ use crate::{
cmd::StringifyErr as _, cmd::StringifyErr as _,
cmd::validate::{ValidationNoticeTarget, handle_validation_notice}, cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
config::{Config, IProfiles, PrfItem}, config::{Config, IProfiles, PrfItem},
core::auto_backup::{AutoBackupManager, AutoBackupTrigger},
core::{ core::{
CoreManager, handle, CoreManager, handle,
validate::{CoreConfigValidator, ValidationOutcome}, validate::{CoreConfigValidator, ValidationOutcome},
}, },
module::auto_backup::{AutoBackupManager, AutoBackupTrigger},
utils::dirs, utils::dirs,
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
@@ -16,7 +16,13 @@ use tokio::fs;
/// 保存profiles的配置 /// 保存profiles的配置
#[tauri::command] #[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> { #[specta::specta]
pub async fn save_profile_file(
index: std::string::String,
file_data: Option<std::string::String>,
) -> CmdResult<ValidationOutcome> {
let index: String = index.into();
let file_data = file_data.map(String::from);
let file_data = match file_data { let file_data = match file_data {
Some(d) => d, Some(d) => d,
None => return Ok(ValidationOutcome::Valid), None => return Ok(ValidationOutcome::Valid),
+28 -13
View File
@@ -8,11 +8,11 @@ use crate::{
}, },
}; };
#[derive(Debug, PartialEq, Eq, serde::Serialize)] #[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome { pub enum ServiceInstallOutcome {
Installed, Installed,
#[cfg(any(windows, test))] #[cfg_attr(not(any(windows, test)), allow(dead_code))]
Sidecar { Sidecar {
reason: String, reason: String,
}, },
@@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
SERVICE_MANAGER.handle_service_status(status).await SERVICE_MANAGER.handle_service_status(status).await
}; };
#[cfg(windows)] #[cfg(windows)]
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await; return finish_service_installation(result, error_code, |reason| async move {
manager.continue_with_sidecar(Some(&reason)).await
})
.await;
#[cfg(not(windows))] #[cfg(not(windows))]
result result
@@ -49,7 +52,7 @@ async fn finish_service_installation<Fallback, FallbackFuture>(
fallback: Fallback, fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome> ) -> CmdResult<ServiceInstallOutcome>
where where
Fallback: FnOnce() -> FallbackFuture, Fallback: FnOnce(String) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>, FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{ {
let Err(error) = installation else { let Err(error) = installation else {
@@ -72,21 +75,20 @@ where
return Err(proxy_aware_coded_error(&error, error_code)); return Err(proxy_aware_coded_error(&error, error_code));
} }
let reason = reasons.join("\n"); let reason = reasons.join("\n");
fallback().await.map_err(|error| { fallback(reason.clone())
proxy_aware_coded_error( .await
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")), .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
"SERVICE_SIDECAR_FAILED",
)
})?;
Ok(ServiceInstallOutcome::Sidecar { reason }) Ok(ServiceInstallOutcome::Sidecar { reason })
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> { pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn uninstall_service() -> CmdResult { pub async fn uninstall_service() -> CmdResult {
CoreManager::global() CoreManager::global()
.uninstall_service_and_start_sidecar() .uninstall_service_and_start_sidecar()
@@ -95,44 +97,58 @@ pub async fn uninstall_service() -> CmdResult {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> { pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> { pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn continue_with_sidecar() -> CmdResult { pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global() crate::core::CoreManager::global()
.continue_with_sidecar() .continue_with_sidecar(None)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
crate::core::notification::sidecar_failure_snapshot()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> { pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice() crate::core::service::take_service_fallback_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> { pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error() crate::core::CoreManager::global().get_startup_error()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_service_repair_notice() -> bool { pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice() crate::core::service::take_service_repair_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> { pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice() crate::core::service::take_service_owner_notice()
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn sync_runtime_providers() { pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE); request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
@@ -175,7 +191,7 @@ mod tests {
None => anyhow::anyhow!("{reason}"), None => anyhow::anyhow!("{reason}"),
} }
.context("install service failed"); .context("install service failed");
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async { let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
fallback_called.set(true); fallback_called.set(true);
anyhow::ensure!(fallback_ready, "another core is running"); anyhow::ensure!(fallback_ready, "another core is running");
Ok(()) Ok(())
@@ -201,7 +217,6 @@ mod tests {
}) })
); );
if rejected { if rejected {
assert!(error.detail.contains(reason));
assert!(error.detail.contains("another core is running")); assert!(error.detail.contains("another core is running"));
} }
} }
+2
View File
@@ -5,11 +5,13 @@ use crate::core::{
/// Returns one coherent core/service snapshot instead of independently refreshed state. /// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_runtime_state() -> Result<RunStateView, String> { pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view()) Ok(RUN_STATE.settled().await.to_view())
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_pending_failures() -> Vec<PendingFailure> { pub async fn get_pending_failures() -> Vec<PendingFailure> {
notification::pending_failures() notification::pending_failures()
} }
+1
View File
@@ -22,6 +22,7 @@ mod platform {
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn invoke_uwp_tool() -> CmdResult { pub async fn invoke_uwp_tool() -> CmdResult {
platform::invoke_uwp_tool() platform::invoke_uwp_tool()
} }
+17 -16
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use crate::core::{ use crate::core::{
handle, handle,
validate::{ValidationErrorKind, ValidationOutcome}, validate::{ValidationErrorKind, ValidationOutcome},
@@ -11,27 +12,27 @@ pub enum ValidationNoticeTarget {
Script, Script,
} }
const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> &'static str { const fn notice_key(kind: ValidationErrorKind, target: ValidationNoticeTarget) -> NoticeStatus {
match kind { match kind {
ValidationErrorKind::FileMissing => "config_validate::file_not_found", ValidationErrorKind::FileMissing => NoticeStatus::ConfigValidateFileNotFound,
ValidationErrorKind::FileRead => match target { ValidationErrorKind::FileRead => match target {
ValidationNoticeTarget::Script => "config_validate::script_error", ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
_ => "config_validate::yaml_read_error", _ => NoticeStatus::ConfigValidateYamlReadError,
}, },
ValidationErrorKind::YamlSyntax => match target { ValidationErrorKind::YamlSyntax => match target {
ValidationNoticeTarget::Merge => "config_validate::merge_syntax_error", ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeSyntaxError,
ValidationNoticeTarget::Script => "config_validate::script_error", ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => "config_validate::yaml_syntax_error", ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlSyntaxError,
}, },
ValidationErrorKind::YamlMapping => match target { ValidationErrorKind::YamlMapping => match target {
ValidationNoticeTarget::Merge => "config_validate::merge_mapping_error", ValidationNoticeTarget::Merge => NoticeStatus::ConfigValidateMergeMappingError,
ValidationNoticeTarget::Script => "config_validate::script_error", ValidationNoticeTarget::Script => NoticeStatus::ConfigValidateScriptError,
ValidationNoticeTarget::Runtime => "config_validate::yaml_mapping_error", ValidationNoticeTarget::Runtime => NoticeStatus::ConfigValidateYamlMappingError,
}, },
ValidationErrorKind::ScriptSyntax => "config_validate::script_syntax_error", ValidationErrorKind::ScriptSyntax => NoticeStatus::ConfigValidateScriptSyntaxError,
ValidationErrorKind::ScriptMissingMain => "config_validate::script_missing_main", ValidationErrorKind::ScriptMissingMain => NoticeStatus::ConfigValidateScriptMissingMain,
ValidationErrorKind::ProcessTerminated => "config_validate::process_terminated", ValidationErrorKind::ProcessTerminated => NoticeStatus::ConfigValidateProcessTerminated,
ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => "config_validate::error", ValidationErrorKind::CoreRejected | ValidationErrorKind::Timeout => NoticeStatus::ConfigValidateError,
} }
} }
@@ -40,12 +41,12 @@ pub fn handle_validation_notice(outcome: &ValidationOutcome, target: ValidationN
ValidationOutcome::Invalid { kind, message } => { ValidationOutcome::Invalid { kind, message } => {
let status = notice_key(*kind, target); let status = notice_key(*kind, target);
logging!(warn, Type::Config, "{} 验证失败: {}", file_type, message); 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 { .. } => { ValidationOutcome::Busy | ValidationOutcome::Skipped { .. } => {
let message = outcome.to_string(); let message = outcome.to_string();
logging!(warn, Type::Config, "{} 验证跳过: {}", file_type, message); 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 => {} ValidationOutcome::Valid => {}
} }
+2
View File
@@ -9,12 +9,14 @@ use clash_verge_draft::SharedDraft;
/// 获取Verge配置 /// 获取Verge配置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> { pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
feat::fetch_verge_config().await.stringify_err() feat::fetch_verge_config().await.stringify_err()
} }
/// 修改Verge配置 /// 修改Verge配置
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult { pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
let operation = system_proxy_operation(&payload); let operation = system_proxy_operation(&payload);
let result = match operation { let result = match operation {
+22 -6
View File
@@ -4,11 +4,18 @@ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core, feat, core, feat,
}; };
use reqwest_dav::list_cmd::ListFile;
use smartstring::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { #[specta::specta]
pub async fn save_webdav_config(
url: std::string::String,
username: std::string::String,
password: std::string::String,
) -> CmdResult<()> {
let url: String = url.into();
let username: String = username.into();
let password: String = password.into();
let patch = IVerge { let patch = IVerge {
webdav_url: Some(url), webdav_url: Some(url),
webdav_username: Some(username), webdav_username: Some(username),
@@ -25,22 +32,31 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
} }
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn create_webdav_backup() -> CmdResult<()> { pub async fn create_webdav_backup() -> CmdResult<()> {
feat::create_backup_and_upload_webdav().await.stringify_err() feat::create_backup_and_upload_webdav().await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> { #[specta::specta]
feat::list_wevdav_backup().await.stringify_err() pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
feat::list_wevdav_backup()
.await
.map(|files| files.into_iter().map(Into::into).collect())
.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_webdav_backup(filename).await.stringify_err() feat::delete_webdav_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { #[specta::specta]
pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_webdav_backup(filename) feat::restore_webdav_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
+6 -6
View File
@@ -308,7 +308,7 @@ impl IClashTemp {
} }
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
pub struct ClashInfo { pub struct ClashInfo {
/// clash core port /// clash core port
pub mixed_port: u16, pub mixed_port: u16,
@@ -364,14 +364,14 @@ fn test_clash_info() {
assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097")); assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097"));
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashExternalControllerCors { pub struct IClashExternalControllerCors {
pub allow_origins: Option<Vec<String>>, pub allow_origins: Option<Vec<String>>,
pub allow_private_network: Option<bool>, pub allow_private_network: Option<bool>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClash { pub struct IClash {
pub mixed_port: Option<u16>, pub mixed_port: Option<u16>,
@@ -387,7 +387,7 @@ pub struct IClash {
pub external_controller_cors: Option<IClashExternalControllerCors>, pub external_controller_cors: Option<IClashExternalControllerCors>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashTUN { pub struct IClashTUN {
pub enable: Option<bool>, pub enable: Option<bool>,
@@ -397,7 +397,7 @@ pub struct IClashTUN {
pub dns_hijack: Option<Vec<String>>, pub dns_hijack: Option<Vec<String>>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashDNS { pub struct IClashDNS {
pub enable: Option<bool>, pub enable: Option<bool>,
@@ -414,7 +414,7 @@ pub struct IClashDNS {
pub nameserver_policy: Option<Vec<String>>, pub nameserver_policy: Option<Vec<String>>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct IClashFallbackFilter { pub struct IClashFallbackFilter {
pub geoip: Option<bool>, pub geoip: Option<bool>,
+17 -10
View File
@@ -1,4 +1,5 @@
use super::{IClashTemp, IProfiles, IVerge, MixedPort}; use super::{IClashTemp, IProfiles, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{ use crate::{
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime}, config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
constants::{files, timing}, constants::{files, timing},
@@ -69,6 +70,12 @@ impl Config {
CONFIG_WRITE_LOCK.lock().await 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<()> { pub async fn init_config_before_window() -> Result<()> {
Self::ensure_default_profile_items().await?; Self::ensure_default_profile_items().await?;
@@ -106,7 +113,7 @@ impl Config {
if let Some((msg_type, msg_content)) = validation_result { if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await; 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(); Self::runtime().await.apply();
@@ -137,14 +144,14 @@ impl Config {
Ok(()) 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 { if let Err(err) = Self::generate().await {
let error_msg: String = err.to_string().into(); let error_msg: String = err.to_string().into();
logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg); logging!(error, Type::Config, "生成运行时配置失败: {}", error_msg);
CoreManager::global() CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg) .use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.await?; .await?;
return Ok(Some(("config_validate::boot_error", error_msg))); return Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)));
} }
logging!(debug, Type::Config, "生成运行时配置成功"); logging!(debug, Type::Config, "生成运行时配置成功");
@@ -171,25 +178,25 @@ impl Config {
error_msg error_msg
); );
CoreManager::global() CoreManager::global()
.use_default_config("config_validate::boot_error", &error_msg) .use_default_config(NoticeStatus::ConfigValidateBootError, &error_msg)
.await?; .await?;
Ok(Some(("config_validate::boot_error", error_msg))) Ok(Some((NoticeStatus::ConfigValidateBootError, error_msg)))
} }
Err(err) => { Err(err) => {
logging!(warn, Type::Config, "验证过程执行失败: {err:#}"); logging!(warn, Type::Config, "验证过程执行失败: {err:#}");
CoreManager::global() CoreManager::global()
.use_default_config("config_validate::process_terminated", "") .use_default_config(NoticeStatus::ConfigValidateProcessTerminated, "")
.await?; .await?;
Ok(Some(("config_validate::process_terminated", String::new()))) Ok(Some((NoticeStatus::ConfigValidateProcessTerminated, String::new())))
} }
} }
} else { } else {
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default(); let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}"); logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
CoreManager::global() CoreManager::global()
.use_default_config("config_validate::error", "") .use_default_config(NoticeStatus::ConfigValidateError, "")
.await?; .await?;
Ok(Some(("config_validate::error", String::new()))) Ok(Some((NoticeStatus::ConfigValidateError, String::new())))
} }
} }
+7 -4
View File
@@ -1,3 +1,4 @@
use crate::core::notify::NoticeStatus;
use anyhow::Result; use anyhow::Result;
use clash_verge_draft::DraftTransaction; use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -105,10 +106,11 @@ impl DnsOverrideState {
} }
} }
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize, specta::Type)]
pub struct ProfileDnsSettings { pub struct ProfileDnsSettings {
pub enabled: bool, pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub confirmation: Option<String>, pub confirmation: Option<String>,
} }
@@ -138,12 +140,13 @@ impl Config {
} }
// The runtime is already committed; a disk failure cannot undo its effective settings. // The runtime is already committed; a disk failure cannot undo its effective settings.
transaction.commit(); transaction.commit();
Handle::refresh_verge();
if state.requested && !state.enabled { if state.requested && !state.enabled {
PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed); 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
} }
} }
+8 -19
View File
@@ -1,4 +1,5 @@
use super::{Config, IClashTemp, IVerge, MixedPort}; use super::{Config, IClashTemp, IVerge, MixedPort};
use crate::core::notify::NoticeStatus;
use crate::{ use crate::{
constants::timing, constants::timing,
core::{ core::{
@@ -14,15 +15,9 @@ use crate::{
use anyhow::{Context as _, Result, anyhow, bail}; use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::DraftTransaction; use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_yaml_ng::Value; use serde_yaml_ng::Value;
use std::{ use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
collections::HashSet,
net::SocketAddr,
str::FromStr as _,
sync::atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct MixedPortFallback { struct MixedPortFallback {
@@ -30,9 +25,8 @@ struct MixedPortFallback {
current: u16, current: u16,
} }
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None)); static PENDING_FALLBACK_NOTICE: Mutex<Option<MixedPortFallback>> = Mutex::new(None);
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false); static STARTUP_CORE_BLOCK_REASON: Mutex<Option<String>> = Mutex::new(None);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
impl Config { impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> { 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) { 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()); *STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string()); report_fallback_error(error.to_string());
} }
pub(crate) fn startup_core_block_reason() -> Option<String> { pub(crate) fn startup_core_block_reason() -> Option<String> {
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) { STARTUP_CORE_BLOCK_REASON.lock().clone()
STARTUP_CORE_BLOCK_REASON.lock().clone()
} else {
None
}
} }
pub(crate) fn notify_startup_mixed_port_fallback() { pub(crate) fn notify_startup_mixed_port_fallback() {
@@ -166,8 +155,8 @@ impl Config {
}; };
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await; tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message( Handle::notice(
"mixed_port::fallback", NoticeStatus::MixedPortFallback,
format!("{},{}", change.original, change.current), format!("{},{}", change.original, change.current),
); );
}); });
@@ -196,7 +185,7 @@ fn report_fallback_error(message: String) {
); );
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await; tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message("mixed_port::fallback_error", message); Handle::notice(NoticeStatus::MixedPortFallbackError, message);
}); });
} }
+29 -11
View File
@@ -27,27 +27,36 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
Some(url.to_string().into()) Some(url.to_string().into())
} }
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)]
#[serde(default)]
pub struct PrfItem { pub struct PrfItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>, pub uid: Option<String>,
/// enum value: remote | local | script | merge /// enum value: remote | local | script | merge
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>, pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>, pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>, pub desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>, pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>, pub extra: Option<PrfExtra>,
@@ -57,19 +66,24 @@ pub struct PrfItem {
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub home: Option<String>, pub home: Option<String>,
#[serde(skip)] #[serde(skip)]
#[specta(type = Option<std::string::String>)]
pub file_data: Option<String>, pub file_data: Option<String>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)]
pub struct PrfSelected { pub struct PrfSelected {
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub now: Option<String>, pub now: Option<String>,
} }
#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Copy, Deserialize, Serialize, specta::Type)]
pub struct PrfExtra { pub struct PrfExtra {
pub upload: u64, pub upload: u64,
pub download: u64, pub download: u64,
@@ -77,9 +91,11 @@ pub struct PrfExtra {
pub expire: u64, pub expire: u64,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)]
pub struct PrfOption { pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub user_agent: Option<String>, pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -102,15 +118,15 @@ pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub allow_auto_update: Option<bool>, pub allow_auto_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub merge: Option<String>, pub merge: Option<String>,
#[specta(type = Option<std::string::String>)]
pub script: Option<String>, pub script: Option<String>,
#[specta(type = Option<std::string::String>)]
pub rules: Option<String>, pub rules: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxies: Option<String>, pub proxies: Option<String>,
#[specta(type = Option<std::string::String>)]
pub groups: Option<String>, pub groups: Option<String>,
} }
@@ -217,6 +233,7 @@ impl PrfItem {
desc: Some(desc), desc: Some(desc),
file: Some(file), file: Some(file),
url: None, url: None,
latency_test_urls: None,
selected: None, selected: None,
extra: None, extra: None,
option: Some(PrfOption { option: Some(PrfOption {
@@ -396,6 +413,7 @@ impl PrfItem {
desc: desc.cloned(), desc: desc.cloned(),
file: Some(file), file: Some(file),
url: Some(url.as_str().into()), url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None, selected: None,
extra, extra,
option: Some(PrfOption { option: Some(PrfOption {
+159 -149
View File
@@ -2,6 +2,8 @@ use super::{
PrfOption, PrfOption,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url}, 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::{ use crate::{
core::{handle, tray::Tray}, core::{handle, tray::Tray},
utils::{ utils::{
@@ -16,6 +18,7 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use std::num::NonZeroUsize;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
path::{Component, Path}, path::{Component, Path},
@@ -45,8 +48,10 @@ const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3); const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
/// Define the `profiles.yaml` schema /// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IProfiles { pub struct IProfiles {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>, pub current: Option<String>,
pub items: Option<Vec<PrfItem>>, pub items: Option<Vec<PrfItem>>,
@@ -245,6 +250,7 @@ impl IProfiles {
patch!(each, item, file); patch!(each, item, file);
patch!(each, item, url); patch!(each, item, url);
patch!(each, item, selected); patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra); patch!(each, item, extra);
patch!(each, item, updated); patch!(each, item, updated);
patch!(each, item, option); patch!(each, item, option);
@@ -600,19 +606,21 @@ fn is_activation_current(generation: u64) -> bool {
} }
async fn fetch_proxies_with_timeout() -> Result<Proxies> { async fn fetch_proxies_with_timeout() -> Result<Proxies> {
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async { let interval = Duration::from_millis(500);
loop { let attempts =
match handle::Handle::mihomo().get_proxies().await { NonZeroUsize::MIN.saturating_add((MIHOMO_OPERATION_TIMEOUT.as_millis() / interval.as_millis()) as usize + 1);
Ok(proxies) => return proxies, tokio::time::timeout(
Err(err) => { MIHOMO_OPERATION_TIMEOUT,
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err:#}"); retry(RetryPolicy::fixed(attempts, interval), |_| async {
tokio::time::sleep(Duration::from_millis(500)).await; handle::Handle::mihomo().get_proxies().await.map_err(|error| {
} logging!(debug, Type::Config, "mihomo proxies are not ready yet: {error:#}");
} RetryError::Retry(error)
} })
}) }),
)
.await .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<()> { async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
@@ -691,51 +699,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. /// 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<()> { pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name); let group_name = String::from(group_name);
let node = String::from(node); let node = String::from(node);
let recorded = Config::profiles() persist_selected_update(true, move |mut profiles| async move {
.await let Some(current) = profiles.current.clone() else {
.with_data_modify(move |mut profiles| async move { return Ok((profiles, false));
let Some(current) = profiles.current.clone() else { };
return Ok((profiles, false)); let Some(item) = profiles
}; .items
let Some(item) = profiles .as_mut()
.items .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
.as_mut() else {
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current))) return Ok((profiles, false));
else { };
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default(); let mut selected = item.selected.clone().unwrap_or_default();
match selected match selected
.iter_mut() .iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name)) .find(|entry| entry.name.as_ref() == Some(&group_name))
{ {
Some(entry) => { Some(entry) => {
if entry.now.as_ref() == Some(&node) { if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false)); return Ok((profiles, false));
}
entry.now = Some(node);
} }
None => selected.push(PrfSelected { entry.now = Some(node);
name: Some(group_name),
now: Some(node),
}),
} }
item.selected = Some(selected); None => selected.push(PrfSelected {
profiles.save_file().await?; name: Some(group_name),
Ok((profiles, true)) now: Some(node),
}) }),
.await?; }
item.selected = Some(selected);
if recorded { profiles.save_file().await?;
supersede_selected_activation(); Ok((profiles, true))
handle::Handle::refresh_profiles(); })
} .await
Ok(())
} }
fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool { fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bool {
@@ -745,36 +760,28 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
} }
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> { pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
let cleared = Config::profiles() persist_selected_update(true, move |mut profiles| async move {
.await let Some(current) = profiles.current.clone() else {
.with_data_modify(move |mut profiles| async move { return Ok((profiles, false));
let Some(current) = profiles.current.clone() else { };
return Ok((profiles, false)); let Some(item) = profiles
}; .items
let Some(item) = profiles .as_mut()
.items .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
.as_mut() else {
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current))) return Ok((profiles, false));
else { };
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default(); let mut selected = item.selected.clone().unwrap_or_default();
if !remove_selected_node(&mut selected, group_name) { if !remove_selected_node(&mut selected, group_name) {
return Ok((profiles, false)); return Ok((profiles, false));
} }
item.selected = (!selected.is_empty()).then_some(selected); item.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?; profiles.save_file().await?;
Ok((profiles, true)) Ok((profiles, true))
}) })
.await?; .await
if cleared {
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
} }
async fn persist_reconciled_selected( async fn persist_reconciled_selected(
@@ -787,34 +794,27 @@ async fn persist_reconciled_selected(
return Ok(()); return Ok(());
} }
let profiles = Config::profiles().await;
let profile_uid = profile_uid.clone(); let profile_uid = profile_uid.clone();
let original_selected = original_selected.to_vec(); let original_selected = original_selected.to_vec();
let updated = profiles persist_selected_update(false, move |mut profiles| async move {
.with_data_modify(move |mut profiles| async move { if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) {
if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) { return Ok((profiles, false));
return Ok((profiles, false)); }
}
let profile = profiles let profile = profiles
.items .items
.as_mut() .as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid))) .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}\""))?; .with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?;
if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() { if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() {
return Ok((profiles, false)); return Ok((profiles, false));
} }
profile.selected = (!selected.is_empty()).then_some(selected); profile.selected = (!selected.is_empty()).then_some(selected);
profiles.save_file().await?; profiles.save_file().await?;
Ok((profiles, true)) Ok((profiles, true))
}) })
.await?; .await
if updated {
handle::Handle::refresh_profiles();
}
Ok(())
} }
/// Returns recorded selections whose groups have not reached the requested node. /// Returns recorded selections whose groups have not reached the requested node.
@@ -836,48 +836,58 @@ fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<Stri
/// Retries selections while provider-backed groups finish loading. /// Retries selections while provider-backed groups finish loading.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) { async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE; let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
loop { let attempts = NonZeroUsize::MIN.saturating_add(
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await; (SELECTED_NODES_SETTLE_DEADLINE.as_millis() / SELECTED_NODES_SETTLE_INTERVAL.as_millis()) as usize,
if !is_activation_current(generation) { );
return; tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
} let _ = retry_with_state(
let Ok(snapshot) = fetch_proxies_with_timeout().await else { RetryPolicy::fixed(attempts, SELECTED_NODES_SETTLE_INTERVAL),
// Unreachable core: the deadline still applies, so this cannot spin forever. (selected, completed, generation, deadline),
if Instant::now() >= deadline { |state, _| {
return; Box::pin(async move {
} let (selected, completed, generation, deadline) = state;
continue; if !is_activation_current(*generation) {
}; return Ok(());
if !is_activation_current(generation) { }
return; let Ok(snapshot) = fetch_proxies_with_timeout().await else {
} return if Instant::now() >= *deadline {
Ok(())
let pending = unsettled_selections(selected, &snapshot); } else {
if pending.is_empty() { Err(RetryError::Retry(()))
return; };
} };
if Instant::now() >= deadline { if !is_activation_current(*generation) {
logging!( return Ok(());
warn, }
Type::Config, let pending = unsettled_selections(selected, &snapshot);
"gave up putting back {} selected node(s) the core never loaded: {}", if pending.is_empty() {
pending.len(), return Ok(());
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ") }
); if Instant::now() >= *deadline {
return; logging!(
} warn,
Type::Config,
let plan = reconcile_selected_nodes(selected, None, &snapshot); "gave up putting back {} selected node(s) the core never loaded: {}",
if apply_activations(&plan.activations, completed, generation) pending.len(),
.await pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
.is_none() );
{ return Ok(());
return; }
} let plan = reconcile_selected_nodes(selected, None, &snapshot);
if is_activation_current(generation) { if apply_activations(&plan.activations, completed, *generation)
handle::Handle::refresh_clash(); .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. /// Releases the first-pass waiter even when restoration exits early.
@@ -920,7 +930,7 @@ async fn activate_selected_nodes_worker(
} }
if is_activation_current(generation) { if is_activation_current(generation) {
handle::Handle::refresh_clash(); announce(Refresh::Clash);
} }
let plan = if needs_confirmation { let plan = if needs_confirmation {
@@ -944,7 +954,7 @@ async fn activate_selected_nodes_worker(
return Ok(()); return Ok(());
}; };
if confirmed_activated_count > 0 && is_activation_current(generation) { if confirmed_activated_count > 0 && is_activation_current(generation) {
handle::Handle::refresh_clash(); announce(Refresh::Clash);
} }
confirmed_plan confirmed_plan
} else { } else {
@@ -1034,7 +1044,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if selected.is_empty() { if selected.is_empty() {
if is_activation_current(generation) { if is_activation_current(generation) {
handle::Handle::refresh_clash(); announce(Refresh::Clash);
} }
return Ok(()); return Ok(());
} }
@@ -1046,7 +1056,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
if let Err(err) = result { if let Err(err) = result {
logging!(error, Type::Config, "failed to activate selected nodes: {err:#}"); logging!(error, Type::Config, "failed to activate selected nodes: {err:#}");
// The profile itself is already active even if node restoration failed. // The profile itself is already active even if node restoration failed.
handle::Handle::refresh_clash(); announce(Refresh::Clash);
} }
update_tray_after_activation(generation).await; update_tray_after_activation(generation).await;
logging!(debug, Type::Config, "activating selected nodes done!"); 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}; use std::{io::ErrorKind, path::PathBuf};
pub(crate) struct FileSnapshot { pub(crate) struct FileSnapshot {
path: PathBuf, pub(crate) path: PathBuf,
content: Option<Vec<u8>>, content: Option<Vec<u8>>,
} }
+45 -14
View File
@@ -11,26 +11,30 @@ use serde::{Deserialize, Serialize};
use smartstring::alias::String; use smartstring::alias::String;
/// ### `verge.yaml` schema /// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVerge { pub struct IVerge {
/// silent | error | warn | info | debug | trace /// silent | error | warn | info | debug | trace
#[specta(type = Option<std::string::String>)]
pub app_log_level: Option<String>, pub app_log_level: Option<String>,
/// Maximum app log size in KiB. /// Maximum app log size in KiB.
pub app_log_max_size: Option<u64>, pub app_log_max_size: Option<u64>,
pub app_log_max_count: Option<usize>, pub app_log_max_count: Option<usize>,
#[specta(type = Option<std::string::String>)]
pub language: Option<String>, pub language: Option<String>,
/// `light` or `dark` or `system` /// `light` or `dark` or `system`
#[specta(type = Option<std::string::String>)]
pub theme_mode: Option<String>, pub theme_mode: Option<String>,
#[specta(type = Option<std::string::String>)]
pub tray_event: Option<String>, pub tray_event: Option<String>,
#[specta(type = Option<std::string::String>)]
pub env_type: Option<String>, pub env_type: Option<String>,
#[specta(type = Option<std::string::String>)]
pub start_page: Option<String>, pub start_page: Option<String>,
#[specta(type = Option<std::string::String>)]
pub startup_script: Option<String>, pub startup_script: Option<String>,
pub traffic_graph: Option<bool>, pub traffic_graph: Option<bool>,
@@ -48,18 +52,23 @@ pub struct IVerge {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub tray_icon: Option<String>, pub tray_icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub menu_icon: Option<String>, pub menu_icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<Vec<std::string::String>>)]
pub menu_order: Option<Vec<String>>, pub menu_order: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub proxy_group_tools_position: Option<String>, pub proxy_group_tools_position: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub notice_position: Option<String>, pub notice_position: Option<String>,
pub collapse_navbar: Option<bool>, pub collapse_navbar: Option<bool>,
@@ -84,39 +93,43 @@ pub struct IVerge {
pub enable_dns_settings: Option<bool>, pub enable_dns_settings: Option<bool>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
#[specta(type = std::collections::BTreeMap<std::string::String, super::dns::ProfileDnsSettings>)]
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>, pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
pub use_default_bypass: Option<bool>, pub use_default_bypass: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub system_proxy_bypass: Option<String>, pub system_proxy_bypass: Option<String>,
pub proxy_guard_duration: Option<u64>, pub proxy_guard_duration: Option<u64>,
pub proxy_auto_config: Option<bool>, pub proxy_auto_config: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub pac_file_content: Option<String>, pub pac_file_content: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxy_host: Option<String>, pub proxy_host: Option<String>,
pub theme_setting: Option<IVergeTheme>, pub theme_setting: Option<IVergeTheme>,
#[specta(type = Option<Vec<std::string::String>>)]
pub web_ui_list: Option<Vec<String>>, pub web_ui_list: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub clash_core: Option<String>, pub clash_core: Option<String>,
/// format: {func},{key} /// format: {func},{key}
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<Vec<std::string::String>>)]
pub hotkeys: Option<Vec<String>>, pub hotkeys: Option<Vec<String>>,
pub enable_global_hotkey: Option<bool>, pub enable_global_hotkey: Option<bool>,
#[specta(type = Option<crate::ipc::JsonValue>)]
pub home_cards: Option<serde_json::Value>, pub home_cards: Option<serde_json::Value>,
pub auto_close_connection: Option<bool>, pub auto_close_connection: Option<bool>,
pub auto_check_update: Option<bool>, pub auto_check_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub default_latency_test: Option<String>, pub default_latency_test: Option<String>,
pub default_latency_timeout: Option<i16>, pub default_latency_timeout: Option<i16>,
@@ -171,6 +184,7 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_url: Option<String>, pub webdav_url: Option<String>,
#[serde( #[serde(
@@ -179,6 +193,7 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_username: Option<String>, pub webdav_username: Option<String>,
#[serde( #[serde(
@@ -187,12 +202,14 @@ pub struct IVerge {
skip_serializing_if = "Option::is_none", skip_serializing_if = "Option::is_none",
default default
)] )]
#[specta(type = Option<std::string::String>)]
pub webdav_password: Option<String>, pub webdav_password: Option<String>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub enable_tray_speed: Option<bool>, pub enable_tray_speed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub tray_proxy_groups_display_mode: Option<String>, pub tray_proxy_groups_display_mode: Option<String>,
pub tray_inline_outbound_modes: Option<bool>, pub tray_inline_outbound_modes: Option<bool>,
@@ -209,27 +226,41 @@ pub struct IVerge {
pub enable_external_controller: Option<bool>, pub enable_external_controller: Option<bool>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVergeTestItem { pub struct IVergeTestItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>, pub uid: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub icon: Option<String>, pub icon: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)]
pub struct IVergeTheme { pub struct IVergeTheme {
#[specta(type = Option<std::string::String>)]
pub primary_color: Option<String>, pub primary_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub secondary_color: Option<String>, pub secondary_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub primary_text: Option<String>, pub primary_text: Option<String>,
#[specta(type = Option<std::string::String>)]
pub secondary_text: Option<String>, pub secondary_text: Option<String>,
#[specta(type = Option<std::string::String>)]
pub info_color: Option<String>, pub info_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub error_color: Option<String>, pub error_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub warning_color: Option<String>, pub warning_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub success_color: Option<String>, pub success_color: Option<String>,
#[specta(type = Option<std::string::String>)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[specta(type = Option<std::string::String>)]
pub css_injection: Option<String>, pub css_injection: Option<String>,
} }
+1 -1
View File
@@ -61,7 +61,7 @@ pub mod files {
} }
pub mod tun { pub mod tun {
pub const DEFAULT_STACK: &str = "gvisor"; pub const DEFAULT_STACK: &str = "mips";
pub const DNS_HIJACK: &[&str] = &["any:53"]; pub const DNS_HIJACK: &[&str] = &["any:53"];
@@ -7,12 +7,11 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use chrono::Local; use chrono::Local;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::{ use std::{
path::PathBuf, path::PathBuf,
sync::{ sync::{
Arc, Arc, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering}, atomic::{AtomicBool, AtomicI64, Ordering},
}, },
time::{Duration, UNIX_EPOCH}, time::{Duration, UNIX_EPOCH},
@@ -92,7 +91,7 @@ pub struct AutoBackupManager {
impl AutoBackupManager { impl AutoBackupManager {
pub fn global() -> &'static Self { pub fn global() -> &'static Self {
static INSTANCE: OnceCell<AutoBackupManager> = OnceCell::new(); static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| { INSTANCE.get_or_init(|| {
let (tx, _rx) = watch::channel(AutoBackupSettings::default()); let (tx, _rx) = watch::channel(AutoBackupSettings::default());
Self { Self {
+2 -3
View File
@@ -4,7 +4,6 @@ use anyhow::Error;
use arc_swap::{ArcSwap, ArcSwapOption}; use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _}; use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use once_cell::sync::OnceCell;
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus}; use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
use smartstring::alias::String; use smartstring::alias::String;
use std::{ use std::{
@@ -12,7 +11,7 @@ use std::{
env::{consts::OS, temp_dir}, env::{consts::OS, temp_dir},
io::Write as _, io::Write as _,
path::PathBuf, path::PathBuf,
sync::Arc, sync::{Arc, OnceLock},
time::Duration, time::Duration,
}; };
use tokio::{fs, time::timeout}; use tokio::{fs, time::timeout};
@@ -58,7 +57,7 @@ pub struct WebDavClient {
impl WebDavClient { impl WebDavClient {
pub fn global() -> &'static Self { 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 { WEBDAV_CLIENT.get_or_init(|| Self {
config: ArcSwapOption::new(None), config: ArcSwapOption::new(None),
clients: ArcSwap::new(Arc::new(HashMap::new())), 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::process::AsyncHandler;
use crate::singleton; use crate::singleton;
use crate::utils::notification::{NotificationEvent, notify_event}; use crate::utils::notification::{NotificationEvent, notify_event};
use crate::utils::window_manager::WindowManager; 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 anyhow::{Result, bail};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
@@ -177,7 +178,7 @@ impl Hotkey {
"Hotkey profile reactivation failed validation: {}", "Hotkey profile reactivation failed validation: {}",
message.as_str() message.as_str()
); );
handle::Handle::notice_message("reactivate_profiles::error", message); handle::Handle::notice(NoticeStatus::ReactivateProfilesError, message);
} }
Err(err) => { Err(err) => {
logging!( logging!(
@@ -186,7 +187,7 @@ impl Hotkey {
"Failed to reactivate subscriptions via hotkey: {}", "Failed to reactivate subscriptions via hotkey: {}",
err err
); );
handle::Handle::notice_message("reactivate_profiles::error", err.to_string()); handle::Handle::notice(NoticeStatus::ReactivateProfilesError, err.to_string());
} }
}); });
} }
+8 -15
View File
@@ -38,7 +38,7 @@ pub(crate) fn proxy_listener_keys() -> impl Iterator<Item = &'static str> {
PROXY_LISTENERS.iter().map(|(key, _, _)| *key) PROXY_LISTENERS.iter().map(|(key, _, _)| *key)
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyPortSettings { pub struct ProxyPortSettings {
pub mixed_port: u16, pub mixed_port: u16,
@@ -48,27 +48,27 @@ pub struct ProxyPortSettings {
pub tproxy: ToggleableProxyPort, pub tproxy: ToggleableProxyPort,
} }
#[derive(Clone, Copy, Debug, Deserialize)] #[derive(Clone, Copy, Debug, Deserialize, specta::Type)]
pub struct ToggleableProxyPort { pub struct ToggleableProxyPort {
pub enabled: bool, pub enabled: bool,
pub port: u16, pub port: u16,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ListenerProbe { pub struct ListenerProbe {
pub address: String, pub address: String,
pub transports: Vec<ListenerTransport>, pub transports: Vec<ListenerTransport>,
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize, specta::Type)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ListenerTransport { pub enum ListenerTransport {
Tcp, Tcp,
Udp, Udp,
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ListenerProbeOutcome { pub enum ListenerProbeOutcome {
Available, Available,
@@ -77,7 +77,7 @@ pub enum ListenerProbeOutcome {
Indeterminate { message: String }, Indeterminate { message: String },
} }
#[derive(Debug, PartialEq, Eq, Serialize)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum SaveProxyPortsOutcome { pub enum SaveProxyPortsOutcome {
Saved, Saved,
@@ -271,10 +271,7 @@ fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]); return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
} }
let normalized = bind_address let normalized = bind_address.strip_circumfix('[', ']').unwrap_or(bind_address);
.strip_prefix('[')
.and_then(|address| address.strip_suffix(']'))
.unwrap_or(bind_address);
Ok(vec![ Ok(vec![
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?, 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 { if port == 0 {
bail!("listener port must be between 1 and 65535"); bail!("listener port must be between 1 and 65535");
} }
let host = host let host = host.trim().strip_circumfix('[', ']').unwrap_or_else(|| host.trim());
.trim()
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or_else(|| host.trim());
if host.eq_ignore_ascii_case("localhost") { if host.eq_ignore_ascii_case("localhost") {
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port)); 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 super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::core::notify::NoticeStatus;
use crate::core::service::StageRequest; use crate::core::service::StageRequest;
use crate::{ use crate::{
config::{Config, IProfiles, runtime::IRuntime}, config::{Config, IProfiles, runtime::IRuntime},
@@ -18,6 +19,7 @@ use smartstring::alias::String;
use std::{ use std::{
collections::HashSet, collections::HashSet,
path::PathBuf, path::PathBuf,
sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri_plugin_mihomo::Error as MihomoError; use tauri_plugin_mihomo::Error as MihomoError;
@@ -129,7 +131,31 @@ where
} }
impl CoreManager { 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; use crate::constants::files::RUNTIME_CONFIG;
let runtime_path = dirs::app_home_dir()?.join(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?; 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(()) Ok(())
} }
@@ -234,6 +260,7 @@ impl CoreManager {
let outcome = self.perform_config_update(Some(profiles)).await?; let outcome = self.perform_config_update(Some(profiles)).await?;
if outcome.is_valid() { if outcome.is_valid() {
crate::config::profiles::restore_selected_nodes().await; crate::config::profiles::restore_selected_nodes().await;
handle::Handle::refresh_clash();
Ok(()) Ok(())
} else { } else {
Err(anyhow!("failed to restore previous Core configuration: {outcome}")) Err(anyhow!("failed to restore previous Core configuration: {outcome}"))
@@ -297,8 +324,27 @@ impl CoreManager {
self.validate_and_apply(transaction).await 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> { 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. // One serialization feeds check and run files; the core never applies unvalidated bytes.
let yaml = Config::runtime_config_yaml().await?; let yaml = Config::runtime_config_yaml().await?;
let outcome = CoreConfigValidator::global() let outcome = CoreConfigValidator::global()
@@ -309,11 +355,34 @@ impl CoreManager {
} }
let run_path = Config::write_runtime_file(&yaml).await?; let run_path = Config::write_runtime_file(&yaml).await?;
self.apply_config(run_path).await?; let previous = self.current_core_readiness_generation();
transaction.commit(); 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) 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. /// Applies a generated configuration through the active core owner.
async fn apply_config(&self, path: PathBuf) -> Result<()> { async fn apply_config(&self, path: PathBuf) -> Result<()> {
if matches!(*self.get_running_mode(), RunningMode::Service) { if matches!(*self.get_running_mode(), RunningMode::Service) {
@@ -405,6 +474,31 @@ mod tests {
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome}; use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
use std::{cell::Cell, time::Duration}; 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); const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
fn staged() -> StageAttempt { fn staged() -> StageAttempt {
+124 -66
View File
@@ -334,6 +334,18 @@ enum HandoffOutcome {
Failed, 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 { impl CoreManager {
async fn rollback_failed_start(&self) { async fn rollback_failed_start(&self) {
proxy_control::stop_guard().await; proxy_control::stop_guard().await;
@@ -364,9 +376,12 @@ impl CoreManager {
} }
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))] #[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() { 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! { defer! {
self.finish_config_update(); self.finish_config_update();
@@ -375,61 +390,68 @@ impl CoreManager {
// draft without claiming it, so without this it can commit another transaction's staged patch. // draft without claiming it, so without this it can commit another transaction's staged patch.
let config_write = Config::lock_config_write().await; let config_write = Config::lock_config_write().await;
let _life = self.lifecycle_lock.lock().await; let _life = self.lifecycle_lock.lock().await;
let status = SERVICE_MANAGER.current().await; // Record failures before releasing the lifecycle lock, including preparation and rollback errors.
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?;
}
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 { let result = async {
self.start_core_inner().await?; let status = SERVICE_MANAGER.current().await;
if !matches!(*self.get_running_mode(), RunningMode::Sidecar) tracing::Span::current().record("status", tracing::field::debug(&status));
|| self.current_core_readiness_generation().is_none() let mode = self.get_running_mode();
{ if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar did not become ready"); anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
} }
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); #[cfg(target_os = "windows")]
self.apply_proxy_after_start().await 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; .await;
if let Err(error) = result { result.map_err(|error| report_sidecar_failure(error, service_rejection))
// 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(())
} }
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))] #[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
@@ -495,7 +517,13 @@ impl CoreManager {
} }
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> { 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<()> { pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
@@ -524,6 +552,8 @@ impl CoreManager {
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady) 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 // 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 // rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast. // offline still fails fast.
@@ -572,6 +602,7 @@ impl CoreManager {
} }
// tell the window when a background apply lands // tell the window when a background apply lands
Handle::refresh_verge(); Handle::refresh_verge();
crate::core::notification::retire_sidecar_failure();
Ok(()) Ok(())
} }
@@ -594,6 +625,8 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup)); tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) { if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await; self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?; self.try_sidecar_after_service_unavailable().await?;
return Ok(()); return Ok(());
@@ -609,7 +642,11 @@ impl CoreManager {
{ {
run_service_start_with_sidecar_fallback( run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(), || 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), |error| self.start_sidecar_after_service_failure(error),
|reason| { |reason| {
crate::core::runstate::RUN_STATE crate::core::runstate::RUN_STATE
@@ -628,6 +665,8 @@ impl CoreManager {
// A failed start must not leave a locally reported running mode. // A failed start must not leave a locally reported running mode.
if result.is_err() { if result.is_err() {
self.rollback_failed_start().await; self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
return result; return result;
} }
@@ -652,7 +691,10 @@ impl CoreManager {
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(), ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()), _ => 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!( logging!(
warn, warn,
Type::Core, Type::Core,
@@ -716,11 +758,17 @@ impl CoreManager {
async fn stop_core_unprepared_inner(&self) -> Result<()> { async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs(); 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::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await, RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()), 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")] #[tracing::instrument(skip_all, level = "info")]
@@ -802,7 +850,12 @@ impl CoreManager {
return; 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; return;
} }
@@ -840,29 +893,34 @@ impl CoreManager {
AsyncHandler::spawn(|| async move { AsyncHandler::spawn(|| async move {
let manager = Self::global(); let manager = Self::global();
let started = Instant::now(); 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 { if started.elapsed() >= timing::SERVICE_HANDOFF_WINDOW {
logging!( logging!(
info, info,
Type::Core, Type::Core,
"service handoff window elapsed; staying in sidecar mode" "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; tokio::time::sleep(timing::SERVICE_HANDOFF_INTERVAL).await;
if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) { if !matches!(*manager.get_running_mode(), RunningMode::Sidecar) {
break; return Ok(());
} }
match manager.try_handoff_sidecar_to_service().await { match manager.try_handoff_sidecar_to_service().await {
HandoffOutcome::Done => break, HandoffOutcome::Done => Ok(()),
HandoffOutcome::Failed => { HandoffOutcome::Failed => {
logging!(warn, Type::Core, "handoff attempt failed; staying in sidecar mode"); 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); manager.handoff_watcher_running.store(false, Ordering::Release);
}); });
} }
+50 -42
View File
@@ -1,15 +1,15 @@
mod config; mod config;
pub(crate) use config::ConfigUpdateGuard;
mod lifecycle; mod lifecycle;
mod state; mod state;
use anyhow::Result; use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption}; use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging}; use clash_verge_logging::{LogRing, Type, logging};
use once_cell::sync::Lazy;
use std::{ use std::{
fmt, fmt,
sync::{ sync::{
Arc, Arc, LazyLock,
atomic::{AtomicBool, AtomicU64, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
}, },
time::Instant, time::Instant,
@@ -21,7 +21,7 @@ use crate::singleton;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle; 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! { tokio::task_local! {
static PROFILE_SELECTIONS_PENDING_COMMIT: bool; static PROFILE_SELECTIONS_PENDING_COMMIT: bool;
@@ -57,7 +57,7 @@ fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64)
.is_ok() .is_ok()
} }
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
pub enum RunningMode { pub enum RunningMode {
Service, Service,
Sidecar, Sidecar,
@@ -75,7 +75,7 @@ impl fmt::Display for RunningMode {
} }
/// Why the Core is not running, kept until a Core starts again. /// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)] #[derive(Clone, Debug, serde::Serialize, PartialEq, Eq, specta::Type)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] #[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure { pub enum CoreFailure {
StartFailed(String), StartFailed(String),
@@ -237,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)) active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
} }
pub(crate) fn invalidate_core_readiness(&self) { pub(crate) fn invalidate_core_readiness(&self) {
let _ = self let _ = self
.core_readiness_state .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) active_core_readiness_generation(current).map(inactive_core_readiness_state)
}); });
} }
@@ -284,48 +284,56 @@ impl CoreManager {
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}"); anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
} }
let mut retries = 0; use crate::utils::retry::{RetryError, RetryPolicy, retry};
loop { retry(
match self.start_core().await { RetryPolicy::fixed(
Ok(()) => { std::num::NonZeroUsize::MIN.saturating_add(MAX_PORT_FALLBACK_RETRIES),
crate::config::Config::notify_startup_mixed_port_fallback(); std::time::Duration::ZERO,
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning)); ),
} |retries| async move {
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => { match self.start_core().await {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) { Ok(()) => {
crate::config::Config::notify_startup_mixed_port_fallback(); 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 { Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
Ok(true) => { if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
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) => {
crate::config::Config::notify_startup_mixed_port_fallback(); crate::config::Config::notify_startup_mixed_port_fallback();
return Err(start_error); return Err(RetryError::Stop(start_error));
} }
Err(fallback_error) => { match crate::config::Config::resolve_startup_mixed_port().await {
crate::config::Config::block_startup_core(&fallback_error); Ok(true) => {
return Err(start_error.context(format!( let retries = retries + 1;
"the mixed proxy port fallback did not rescue core startup: {fallback_error:#}" 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
}
}
}
} }
} }
+58 -33
View File
@@ -1,6 +1,7 @@
#[cfg(test)] #[cfg(test)]
use super::claim_core_readiness_generation; use super::claim_core_readiness_generation;
use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode}; use super::{CoreManager, PROFILE_SELECTIONS_PENDING_COMMIT, RunningMode};
use crate::utils::retry::{RetryError, RetryPolicy, retry};
use crate::{ use crate::{
AsyncHandler, AsyncHandler,
config::Config, config::Config,
@@ -11,10 +12,20 @@ use crate::{
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use clash_verge_logging::Type; use clash_verge_logging::Type;
use log::Level; use log::Level;
use std::num::NonZeroUsize;
use std::path::Path; use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt 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")] #[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> { fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
@@ -41,14 +52,15 @@ async fn retry_service_start<Start, StartFuture>(
mut start: Start, mut start: Start,
) -> Result<()> ) -> Result<()>
where where
Start: FnMut() -> StartFuture, Start: FnMut() -> StartFuture + Send,
StartFuture: std::future::Future<Output = Result<()>>, StartFuture: std::future::Future<Output = Result<()>> + Send,
{ {
let mut last_error = None; let attempts = NonZeroUsize::new(attempts).ok_or_else(|| anyhow::anyhow!("service start failed"))?;
for attempt in 0..attempts { retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
match start().await { // The Windows Service IPC future would otherwise inflate every enclosing lifecycle future.
Ok(()) => return Ok(()), let future = Box::pin(start());
Err(error) => { async move {
future.await.map_err(|error| {
logging!( logging!(
warn, warn,
Type::Core, Type::Core,
@@ -60,16 +72,14 @@ where
.downcast_ref::<service::ServiceStartRefusal>() .downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code)) .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 { impl CoreManager {
@@ -95,14 +105,16 @@ async fn poll_sidecar_readiness<F, Fut>(
mut probe: F, mut probe: F,
) -> Result<()> ) -> Result<()>
where where
F: FnMut() -> Fut, F: FnMut() -> Fut + Send,
Fut: std::future::Future<Output = Result<()>>, Fut: std::future::Future<Output = Result<()>> + Send,
{ {
let mut last_error = None; let attempts = NonZeroUsize::new(max_attempts)
for attempt in 0..max_attempts { .context("sidecar readiness was configured with no attempts")
match probe().await { .context("Mihomo API did not become ready")?;
Ok(()) => return Ok(()), retry(RetryPolicy::fixed(attempts, retry_delay), |attempt| {
Err(error) => { let future = probe();
async move {
future.await.map_err(|error| {
logging!( logging!(
debug, debug,
Type::Core, Type::Core,
@@ -110,16 +122,12 @@ where
attempt + 1, attempt + 1,
max_attempts max_attempts
); );
last_error = Some(error); RetryError::Retry(error)
} })
} }
if attempt + 1 < max_attempts { })
tokio::time::sleep(retry_delay).await; .await
} .context("Mihomo API did not become ready")
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
.context("Mihomo API did not become ready"))
} }
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool { fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
@@ -155,7 +163,8 @@ impl CoreManager {
self.wait_for_sidecar_exit().await?; self.wait_for_sidecar_exit().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar() let execution = clash_verge_service_ipc::execution::reserve_sidecar()
.await .await
.inspect_err(service::record_residual_service)?; .inspect_err(service::record_residual_service)
.map_err(explain_core_occupancy)?;
self.core_stopped(); self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?; let sidecar_ipc = dirs::sidecar_ipc_path()?;
@@ -248,7 +257,7 @@ impl CoreManager {
match event { match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(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); Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message); CLASH_LOGGER.append_log(message);
} }
@@ -394,6 +403,8 @@ impl CoreManager {
self.set_job_handle(None); self.set_job_handle(None);
proxy_control::stop_guard().await; proxy_control::stop_guard().await;
self.core_stopped(); self.core_stopped();
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
} }
} }
@@ -510,6 +521,20 @@ mod readiness_tests {
time::Duration, 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] #[tokio::test]
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> { async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
let attempts = Arc::new(AtomicUsize::new(0)); let attempts = Arc::new(AtomicUsize::new(0));

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