Compare commits

...
Author SHA1 Message Date
oomeow 53f204245a chore: update tauri-plugin-mihomo deps 2026-08-01 18:22:17 +08:00
oomeow 24ae883604 fix(runstate): reduce retry count from 10 to 6 2026-08-01 18:01:16 +08:00
oomeow 5e327df437 fix(runstate): remove unused anyhow::Error import 2026-08-01 17:29:05 +08:00
oomeow cf29b714d3 feat(runstate): retry service version probing 2026-08-01 17:01:39 +08:00
oomeow 8ef6f0cc23 chore: update tauri-plugin-mihomo deps (#7647)
* chore: update tauri-plugin-mihomo deps

* clippy
2026-07-31 14:02:20 +00:00
7d8f98e183 fix(macos): keep DNS state outside app bundle (#7544)
* fix(macos): keep DNS state outside app bundle

* fix(macos): restore legacy DNS state

---------

Co-authored-by: Arcanum <260421450+ArcanePivot@users.noreply.github.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-07-31 09:44:56 +08:00
ef179fcf61 fix(home): manual update check not working when auto_check_update disabled (#7585)
* fix(config): prevent cleanup_orphaned_files from deleting active profiles

When IProfiles::new() fails to parse profiles.yaml (e.g., corrupt file), it
returns Self::default() with items: None. The subsequent
cleanup_orphaned_files() call then sees no active files and deletes all
profile files in the profiles/ directory except the two hardcoded protected
files (Merge.yaml and Script.js).

Guard cleanup_orphaned_files() against this by checking if items is None
or empty before proceeding. When items is unavailable, skip the cleanup
and log a warning instead of blindly deleting user data.

Fixes #7577

* fix(home): manual update check not working when auto_check_update disabled

When auto_check_update is false, useUpdate() sets enabled=false for the
SWR query, which sets the SWR key to null. Calling swr.mutate() on a null
key does not execute the queryFn, returning undefined instead.

This caused the home page's "last check time" click to instantly show
"already up to date" without actually checking for updates.

Fix: use revalidateQuery() for the manual checkUpdate function, which
uses the global SWR mutate with the actual query key, bypassing the
enabled: false state and always triggering a real network request.

Fixes #7584

* fix: preserve manual update and profile data

---------

Co-authored-by: Evan Gong <evan@users.noreply.github.com>
Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-07-31 06:52:10 +08:00
oomeow 4b3bc54b2d fix(ws): assign websocket after onConnected succeeds (#7639)
* feat(layout): add logging of MihomoWebSocket instances in dev mode

* refactor(hooks): defer ws assignment in use-mihomo-ws-subscription

* chore: update tauri-plugin-mihomo-api dependency source to use github protocol

* docs: update changelog with websocket connection fix
2026-07-30 15:33:45 +00:00
oomeow 10dbe60671 fix(proxy): fix inline providers being filtered after Mihomo API change (#7640)
* feat(proxy): add Inline vehicle type support

* docs: add fix for Mihomo API proxy-provider filtering to changelog
2026-07-30 14:49:38 +00:00
oomeow 07a1515d8c feat(logging): introduce ModuleFilter with exclusion support and logging tauri_plugin_mihomo (#7637)
* feat(logging): introduce ModuleFilter with exclusion support

* refactor(logging): simplify exclude check using if-let chain
2026-07-30 12:48:22 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 5cbb0b6cfd chore(deps): update github/gh-aw-actions action to v0.84.0 (#7630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 04:00:58 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 3de4d53b8f chore(deps): update github/gh-aw-actions action to v0.83.5 (#7627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 15:04:20 +00:00
oomeow f69d03c4d4 refactor(dns-viewer): use matchAll for name server policy parsing (#7626)
* refactor(dns-viewer): use matchAll for name server policy parsing

* chore: biome lint

* chore(biome): disable noExplicitAny rule in linter settings
2026-07-29 12:37:57 +00:00
wonfen b98c9be7db fix: keep current proxy menus interactive 2026-07-29 15:36:07 +08:00
wonfen 7e0cf4f9f6 fix: remove invalid platform reference from macOS dev anchor 2026-07-29 14:15:01 +08:00
wonfen 98d70978e4 fix(service): keep health probes and sidecar fallback consistent 2026-07-29 12:39:24 +08:00
StarsSail 7fb5001bea fix: enable shell option for child_process.spawn on Windows (#7619)
* fix: enable shell option for Windows in runDevCommand

* fix: enable shell option for Windows in runDevAnchor
2026-07-29 03:52:48 +00:00
wonfen 593ac07f03 fix(service): restore sidecar fallback after failed actions 2026-07-29 11:22:10 +08:00
oomeow 382cbf9fb5 fix(dns-viewer): parse empty to yaml error (#7616)
* feat(dns-viewer): initialize yamlContent with empty object string

* feat(dns-viewer): prevent overwriting user config with initial YAML values

* fix(dns-viewer): add null check for parsed yaml object
2026-07-29 00:53:38 +00:00
wonfen 3d8d7731e1 fix: node selection persistence (#7620)
* fix(core): put the user's node choices back when the core starts

mihomo restores the node picked in each group from the `cache.db` it keeps
in the directory it was started against. The app relied on that entirely,
and it does not hold: `profile.store-selected` comes from a merge template
a user is free to replace, and a service older than the durable runtime
generation starts every core in a directory nothing has ever run in. Both
end the same way — every `select` group falls back to the first entry in
its `proxies:` list.

The app already knows better. It records each selection in the profile,
and `activate_selected_nodes` already reconciles that record against the
running core — it was only ever called when switching profiles, never when
a core came up. Call it from the three places a core process becomes
ready. Repeat calls supersede each other, so no coordination is needed
between the start paths.

That makes the profile authoritative, which turns any selection it does
not know about into one the next start silently undoes. The chain proxy
moved a group without recording it, so recording is now one shared hook
that both it and the proxy list go through.

Restoration is spawned, not awaited: it polls the core until its groups
are readable, and a start must not wait for that.

* fix(tray): record the node the tray switched to

Restoring the profile's selections when a core starts made the profile
authoritative, which turns any selection it never learned about into one
the next start silently undoes. The proxy list and the chain proxy both
record; the tray did not.

So switching node from the tray survived only until the next restart, and
then reverted to whatever the profile last knew — a regression the
restoration itself introduced, and only for the tray.

`record_selected_node` is the backend counterpart of the frontend's
`useRecordSelection`. Failing to record does not fail the switch: the core
is already on the new node by then, and reporting the switch as failed
would be wrong. It is logged instead.

* fix(core): restoring selections must not prune what the core has not loaded

Putting the profile's selections back when a core starts reused the
reconciliation written for profile switches, and that one also *repairs*:
a record whose group is absent from two snapshots a second apart is
dropped, and the reduced list is written to profiles.yaml.

After a profile switch the configuration is loaded, so absence is
evidence. After a core start it is not — a provider-backed group can be
empty or missing for seconds — and a record pruned on that evidence is
gone for good, because the provider finishing later cannot bring it back.
Restoring now keeps the records and only applies what it can match;
pruning stays with the caller entitled to conclude it.

The restore is also asynchronous: it reads the profile, polls the core
until its groups are readable, then puts its selections. A choice the user
makes inside that window is newer than what it captured, so letting it
finish pushed the core back to the older node while the profile already
held the newer one. Recording a selection now supersedes any activation in
flight — from the tray, and from the UI and chain proxy through
`patch_profile`.

* fix(core): activate the selections of the profile that is actually running

The activation worker read the profiles draft. A profile switch stages its
target there before validating it, so a switch that fails — leaving the
previous profile running — could still have handed an activation the
rejected profile's selections, which it would then apply to whichever
same-named groups the running profile has.

Reading the committed state removes the window. The switch that succeeds
commits before it activates, so nothing changes for that caller; what
changes is that a restore running at core start can no longer observe a
switch that never happened.

* fix(core): stop a restoration from undoing a choice made while it runs

Two gaps in making the profile authoritative, both found by adversarial
review of the commits that introduced it.

A restoration reads the profile's selections once, then polls the core
until its groups can be read — seconds, on a cold start. A selection made
inside that window is newer than what the restoration is carrying, and
nothing told the restoration so: it went on to apply the older choice over
the user's. The UI accepted the new node and traffic snapped back to the
old one, with the profile and the running core then disagreeing. Recording
a selection now abandons any restoration still in flight, through the same
generation counter every other superseding caller uses.

The other gap is the home card in global mode, which passed
`skipConfigSave` and so recorded nothing. That was harmless while nothing
re-applied the profile; now it means the next start undoes the choice. It
was the only caller that ever set the flag, so the flag is gone rather than
flipped — a parameter whose one use was a bug is not worth keeping.

* fix(core): wait for the selections that can be put back, keep trying for the rest

Two gaps in restoring the profile's selections when a core starts, both
found by adversarial review.

Restoring re-checked once, a second later, and stopped. That is enough
after a profile switch, which happens with the configuration already
loaded. A core start is the opposite case: a provider-backed group is
present but empty until its provider finishes loading, and a `select` can
fail while the core is still settling. Neither produces an activation, so
the restore reported success having applied nothing and the group stayed
on the first entry of its `proxies:` list — the outcome restoring exists
to prevent. It now keeps looking, on a deadline, at what the core is
actually on rather than at what it managed to send.

Restoring was also spawned, and the caller enables the system proxy the
moment the start returns — so traffic could be pointed at a core still on
the wrong node. The first pass is now awaited, bounded, and the rest keeps
settling in the background: a core that will not answer delays a start
rather than blocking it, because being proxied through the wrong node
beats not being proxied at all.

TUN is deliberately not covered, and cannot be by anything here: it is
configured in the core's own file, so it carries traffic from the moment
the process starts. What puts a TUN user on the right node from the first
packet is the core's own `cache.db`, which is why the service keeps an
owner's runtime generation across restarts.

`supersede_selected_activation` and `cancel_selected_node_activation` were
the same function under two names, added independently for the same
reason, and `patch_profile` called both. One survives, called once, after
the profile is written — the only placement that needs to exist, since an
activation that read the older profile is cancelled by it and one starting
afterwards reads the newer one.

* fix(core): record a selection by naming it, not by resending the list

Recording a node selection sent the whole selection list, built from the
profile the frontend had rendered. Two selections made before that list
refreshed were therefore built from the same stale snapshot, and the later
write dropped the earlier group's choice. Since a core start re-applies
whatever the profile holds, the dropped one came back on the next restart
— the profile and the running core disagreeing, in the direction the user
would least expect.

The tray already avoided this, because it went through
`record_selected_node`, which merges one group into the profile inside
`with_data_modify`. Exposing that as a command and pointing the frontend
at it leaves one way to record a selection instead of two, and removes the
list from the wire: what a caller knows is the group and the node, and
that is now all it says.

`useRecordSelection` no longer reads the profile at all, which also makes
it stable across renders rather than changing identity whenever the
profile does.

* test(core): make every way of declining to stage answer for itself

`CoreRestarted` was added to `StageRejection` upstream and reached
production without a test. It inherited the right verdict — every
`RestartRequired` is matched together — but nothing said so, and nothing
would have noticed if it had inherited the wrong one.

The list of rejections now goes through a wrapper whose `match` is
exhaustive, so the next variant added upstream fails to compile here. That
is the part worth having: it does not prove the list is complete, but it
does mean nobody can add a way to decline without being sent to this test
to say what it should lead to.

* build(deps): pin the service protocol to v2.6.1

The pin tracked `feat/stage-runtime` because the protocol change it
carried had no tag yet. It has one now, and a branch pin makes the build
irreproducible: an upstream force-push silently changes what this
compiles against.

v2.6.1 carries the runtime generation an owner keeps across restarts,
which is what lets the core hold on to the `cache.db` its node selections
and fake-ip leases live in. The protocol itself is untouched — epoch,
revision and both compatibility floors are byte-identical to v2.6.0 — so
this is a fix release in both directions: an older service still works
with this app, and this app still works with an older service, only
without the durable generation.

The lockfile change is the two lines that name it. Reaching for
`cargo update -p` instead re-resolved a subtree and quietly downgraded
`windows-sys`, `windows-core` and `getrandom` along the way.
2026-07-29 07:47:52 +08:00
Tunglies 5590f6ab15 fix: scope windows-sys dependency to Windows 2026-07-29 05:42:40 +08:00
wonfen cefcb150c9 fix: core runstate review/improve (#7617)
* fix(core): reopen PAC when a start attempt never happens

`save_proxy_ports` closes the PAC endpoint before it stages a candidate,
because the endpoint resolves the Mixed Port through the draft layer and
would otherwise answer with a port nothing is listening on.

Most ways out of that function are a rejection, though: the candidate is
refused and the Core keeps serving on its old ports, having never been
stopped. PAC is re-derived only when the Running Mode changes, and that
is exactly what did not happen — so the endpoint stayed shut for the rest
of the session, and every browser on PAC silently went direct.

Give `core_starting` the counterpart it was missing. `core_start_settled`
re-derives PAC from the Running Mode rather than restoring a saved flag,
which makes it correct on all three endings — a start that succeeded, one
that was abandoned, one that failed and left the Core down — and therefore
safe to run from a guard on every path.

* fix(config): refuse overlapping draft transactions instead of losing an edit

A layer holds exactly one draft, so a second writer's `edit_draft`
overwrites the first writer's staged value in place. `DraftTransaction`
nonetheless rolled back by calling `discard` on the shared layer, which
discarded whatever was staged there — including a change another
transaction was about to commit.

The other transaction then found no draft, and `apply` on an absent draft
does nothing at all. It saved the committed value, returned Ok, and its
side effect had already happened: auto-launch registered with the OS while
the config that describes it says otherwise, and no log line either way.

"Roll back only my edit" cannot be implemented over a single slot, so
enforce the exclusivity the type already assumed. Beginning a transaction
claims every layer and refuses rather than interleaving, which is the same
shape as `try_start_config_update` and `begin_operation` elsewhere.

`patch_verge` is the caller that matters: it holds its draft across
`process_terminated_flags`, which restarts the Core and can take seconds.
The TUN reconciliation runs on every Run State transition and can now lose
that race, so it treats a busy layer as "later" rather than as a failure
worth telling the user about.

* fix(core): reconcile TUN availability when the setting itself changes

Moving the TUN availability guard from the frontend into Rust also moved
what triggers it. The old effect depended on `enable_tun_mode`; the new
reconciliation is spawned from `RealEnv::publish`, so it only runs on a
Run State transition. Those two sets are not the same, and the setting
itself falls in the gap.

Switching TUN on from the global hotkey is the case that reaches it. That
path is not gated on availability — unlike the tray item and the settings
switch, both of which read `tun_capable` — and the flags a TUN patch
raises are answered by a Core reload, which changes no Run State at all.
So on a machine with no Service and no elevation the setting stayed on:
tray icon, settings switch and `enable_tun_mode` in verge.yaml all
reported TUN as enabled while nothing carried its traffic, until some
unrelated core lifecycle transition happened to reconcile it.

Reconcile after any patch that touched the setting. The reconciliation
writes configuration itself, so it goes through the plain apply rather
than back through the patch-and-reconcile form; the absence of a cycle is
then a property of the call graph rather than of a runtime early-return.

`toggle_tun_mode` now reads the setting back instead of returning what it
asked for, so the hotkey cannot report TUN as on moments before the notice
saying it was turned off.

* fix(core): an action already under way is not a decision to put to the user

The legacy status could not distinguish a requested privileged operation
from one already running, because readers blocked until the operation
finished and so never observed the request at all. Run State pushes every
transition instead, and `service_needs_attention` treated any pending
action as a question for the user.

So an install started from the settings page or the home card raised the
migration dialog on top of it, and an uninstall raised one offering to
reinstall the Service being removed. Both offer a button that can only
fail, since the privileged-operation slot is already taken.

An operation in flight is a report of progress. The dialog the user is
already driving keeps itself open through its own loading state.

`tun_should_be_disabled` is unaffected: it already refused to act while an
operation was in flight, so the term this adds is redundant there.

* fix(core): let the snapshot decide whether the Run State has settled

`settled` read the operation slot twice: once to decide whether to wait,
then again inside the snapshot it returned. An operation claiming the slot
between those two reads was described rather than waited for.

`prepare_startup` reads this. A start racing an install would see the
requested install, flatten it to `InstallRequired`, and decide `Wait` —
leaving the core stopped where waiting for the install to finish would
have told it whether the Service came back.

Read the slot once, inside the snapshot, so there is no window rather than
a second check closing it. The rewrite that introduced this also fixed a
lost wakeup here, and that fix stands: the waiter is still registered
before the state is read.

* fix(ui): re-read the Run State once its listener is live

Replacing the poll with a push left a gap. `listen` resolves
asynchronously, so between the first `get_runtime_state` and the
subscription being registered there is a window, and a transition landing
in it is not queued anywhere — it is simply missed. The next event is
whenever the backend happens to move again, which during startup can be a
long time; the 30-second safety net was all that stood behind it, where
the old code polled every two seconds precisely through startup.

The Run State is the one thing here that arrives only by event, so it is
the one thing a missed event leaves stale. Re-read it once every listener
is live, which closes the gap without going back to polling for it.

`onSubscribed` fires even when a registration failed, because a resync is
most needed when a subscription is missing, and not at all after teardown.

* docs(config): state what makes the PAC endpoint's draft read safe

`MixedPort::desired` reads the draft layer, so the PAC endpoint can name a
port the Core has not moved to yet. That is only safe because the one path
staging a listener port closes PAC across the change. Nothing said so at
either end, and the two are in different modules.

* fix(core): ask again when staging goes silent rather than assume it failed

The Service commits the generation before it answers, so a reply that never
arrives is not evidence that nothing was written: config.yaml can already hold
the new configuration while this side concludes the staging never happened. The
Core keeps running the previous configuration, so nothing looks wrong -- until
the Core dies and the Service's watchdog restarts it in the same generation from
whatever config.yaml says, quietly making live a configuration the app reported
as failed.

Silence is the one answer that has to be resolved rather than acted on, so ask
once more before falling back. It is cheap exactly where it matters: staging
plans against the manifest it wrote, so a bundle that already landed copies
nothing and answers in milliseconds, and the Service's lifecycle lock makes a
second ask wait for a first one still in flight instead of interleaving with it.
A deadline keeps that wait off the caller, and a second silence goes where the
first one used to.
2026-07-29 03:58:47 +08:00
oomeow b71a1e4ec5 refactor: mihomo ipc (#7601)
* refactor: mihomo ipc request

* refactor: remove redundant write locks and await on mihomo handle

* fix(rule): handle non-string types in ProviderButton labels

* refactor: handle non-string value types in RuleItem component

* refactor(tauri): remove unnecessary await in get_base_config call

* chore: update tauri-plugin-mihomo deps

* refactor: rename ConnectionId to WsConnectionId

* chore: update tauri-plugin-mihomo-api dependency reference

* chore(config): use Proxy::default in profile test initialization
2026-07-28 15:16:01 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
3515c79e2b chore(deps): update rust crate compact_str to 0.10.0 (#7608)
* chore(deps): update rust crate compact_str to 0.10.0

* fix(deps): align compact_str versions

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-07-28 10:47:51 +00:00
Tunglies 95bd5ceed9 fix: update js-yaml imports for ESM 2026-07-28 16:21:06 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
dca6fdcae6 chore(deps): update cargo dependencies (#7443)
* chore(deps): update cargo dependencies

* fix(deps): align shared Rust dependency versions

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-07-27 19:02:22 +00:00
Tunglies 2f7e5771ac fix(ci): update agentic workflow runtime 2026-07-28 03:12:49 +08:00
Tunglies 912f93fe85 fix: resolve default-feature clippy errors 2026-07-28 02:45:14 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
1cf6e2c6bd chore(deps): update npm dependencies (#7454)
* chore(deps): update npm dependencies

* fix(deps): restore frontend check compatibility

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-07-27 18:20:26 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
aa6237e86b chore(deps): update npm dependencies (major) (#7380)
* chore(deps): update npm dependencies

* refresh lockfile

* fix(deps): restore TypeScript 6 compatibility

* fix(deps): remove redundant js-yaml types

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-07-28 01:37:11 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f41dea9622 chore(deps): update github/gh-aw-actions action to v0.83.4 (#7603)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-27 10:45:29 +00:00
wonfen c90de1355e refactor: core runstate (#7602)
* refactor(core): introduce core::runstate as the owner of Run State

"Is the Service available?" had five caches and three predicates, and
prepare_startup consulted all three within thirty lines. This lands the
foundation for collapsing that, with no behaviour change: the old owners
now delegate into one store.

The state model splits observation from intent. Five of ServiceStatus's
ten variants were never observations — cmd/service.rs passed
ServiceStatus::InstallRequired as the "which operation do you want"
argument — which is why narrowing to ServiceInstallState had to be lossy.
ServiceHealth now records what we saw, PendingAction records what the
session asked for, and ServiceStatus::from_run_state flattens the pair
for call sites that still expect the single slot.

Reads differ only in freshness: state() is cached, settled() waits for an
in-flight privileged operation, probe() forces IPC. What an answer means
lives on the snapshot (service_usable, tun_capable), so callers stop
writing their own formulas — the tray now asks tun_capable() instead of
recomputing "admin or ready".

Effects sit behind RunStateEnv with two adapters, RealEnv and FakeEnv.
That retires the seven tests that hand-built a private ServiceManager
struct literal, and makes the probe and detection paths testable for the
first time.

Two distinctions are now explicit rather than incidental:

  - A single failed probe is not an observation and leaves confirmed
    health intact; exhausting a poll's whole retry budget is one and
    records unavailable. Both are tested, protecting 389c5342.
  - Platform evidence that cannot be inspected is unavailable, not
    absent, so we never offer to install a Service that is already there.

RealEnv::is_elevated reads APP_HANDLE without expect(), so reading Run
State before the app handle exists degrades to "not elevated" instead of
aborting.

Tests: 180 -> 218. One test was removed; its assertions moved into
runstate::probe.

* refactor(core): derive PAC and the core-mode mirror from Run State

Running Mode had twelve write sites, PAC availability ten, and the
frontend mode mirror seven — all three kept in step by convention, each
pairing written out by hand next to the others. Nothing checked the
pairing, so a new call site could set a mode and leave PAC serving a
script for a proxy port nothing was listening on.

Callers now state what happened rather than what to store:

  core_started(Sidecar|Service)   the Core is running and serving
  core_stopped()                  the Core is not running
  core_starting()                 a start attempt is under way

PAC availability and the mode mirror are derived from those transitions
inside the store and nowhere else, so they cannot drift. set_mode is
private; after_core_process is deleted along with all seven of its calls.

Ordering is fail-closed in both directions: PAC closes before the mode
changes and opens after it, so a reader that catches a transition
mid-flight is refused rather than handed a stale script. core_starting
exists for exactly that window during a handover, where the Core being
replaced is still the one that holds the mode.

Both effects sit behind RunStateEnv, so FakeEnv records them instead of
touching process globals, and the derivation is asserted directly.
RealEnv publishes the mirror without expect(), so a Core that stops
before the app handle exists no longer risks an abort.

utils::server's fail-closed test now asserts the default rather than the
live flag: PAC follows the Running Mode, so any test driving a Core
transition legitimately moves it. The invariant that PAC can never
disagree with the mode is asserted in core::runstate instead, where the
environment is a fake.

Tests: 218 -> 223.

* refactor(core): absorb the service wait and owner monitor into Run State

Four loops asked the same question — is the Service there? — with four
retry policies, and the one that mattered most was the least examined:
112 lines of owner monitor with three copy-pasted counter blocks and no
tests, where a missed reset silently changes when the app tears down a
working proxy.

The readiness wait becomes one interface. Its two callers wanted
different things from a failed wait, and the old code expressed that
implicitly in whether a poll returned Ok — a poll returned Ok for a
readable-but-incompatible reply, which is why one caller recorded
"unavailable" and the other did not. ReadyWaitError now says which
happened: silence is the caller's to interpret, a rejected reply already
recorded its own verdict and must not be flattened into a vaguer one.
Retrying is likewise only for silence; twenty retries of a known-bad
version would just delay telling the user to reinstall.

The owner monitor's decision moves to core::runstate::owner as a state
machine over samples. A transport error, an error code and an empty
payload were three near-identical blocks; they are one Unreadable sample
now, because they mean the same thing: we learned nothing. The two
counters are separate and independent — silence says nothing about the
Core's PID — and VerifyTransport keeps the rule that a Service we cannot
reach while the Core still answers is a broken status channel, not a
lost Core. 15 tests, where there were none.

Privileged operations run through the environment, so the state around
them is testable: a refused uninstall leaves the Service untrusted
rather than absent, and a refused install does not claim it is gone.
That also retires PrivilegedServiceAction, which duplicated
PendingAction — apply_service_status now reads back the one mapping
set_status already performed instead of re-deriving it.

Fixes a test-isolation regression from the first stage: moving Running
Mode out of CoreManager's per-instance State made three tests share one
process-wide mode, which flaked once during this work. CoreManager now
holds a reference to its Run State, so a test can supervise a Core
without racing every other test.

Tests: 223 -> 241, verified stable over repeated runs.

* test(frontend): wire vitest and revive the orphaned proxy-view tests

There was no frontend test runner, and tests/proxy-view.test.mjs — 421
lines covering the proxy view's identity and rebinding rules — was
referenced by no script and no workflow. Unsurprisingly it had rotted:
three of its ten tests called functions that no longer exist.

vitest runs it now, and CI runs vitest. The file keeps
node:assert/strict so every surviving assertion means exactly what it
did before; only the harness import changed.

Of the three broken tests, one was salvageable: rebindNode is still live
and the deleted toNodeBinding was just { name, source }, so inlining it
restores the test unchanged. The other two covered findGroup and
selectRuntimeStandaloneNodes, which are gone; they are deleted rather
than guessed at. If the behaviour they described still exists under
another name, it is currently uncovered.

The revived file is deliberately *not* type-checked. It was written as
untyped .mjs — widened literals, runtime asserts that do not narrow —
and retyping it would mean rewriting assertions I am trying to preserve.
It is registered with ESLint's default project instead. New tests belong
under src/**/*.test.ts, which tsconfig already covers, so they get full
type checking from the start; untyped tests are how this one rotted.

Only vitest is added. Testing-library comes with the first hook test
that needs it, so knip does not flag an unused dependency in between.

* refactor(core): one Run State snapshot across the IPC seam

Three commands described one fact — running mode, service install state,
elevation — under three query keys with three refresh intervals, and the
frontend kept them coherent by writing two caches by hand after every
service action. get_runtime_state replaces all of it with one snapshot
under one key.

The snapshot carries the derived answers, not just the fields:
tunCapable, serviceUsable and serviceNeedsAttention are computed in Rust
and travel with it. That is the point — "TUN can work" had three
definitions across the tray, the startup path and a React hook, and the
migration dialog decoded an enum ladder to decide whether to open.

Transitions are pushed. Every Run State change emits
verge://run-state-changed carrying the new snapshot, which the frontend
writes straight into the cache instead of refetching. Polling drops from
2s-during-startup to a 30s safety net, and the startup grace period goes
away: the service reports 'unknown' until it has actually been probed,
so the guard no longer has to wait out a fixed 10s to know whether to
trust the answer.

ServiceInstallState is deleted along with the lossy install_state()
narrowing that motivated splitting observation from intent in the first
place. So is the isAdmin wrapper — elevation is part of the snapshot now.

app_core_mode is deliberately kept: it is not a redundant mirror, it
feeds export_diagnostic_info's "Running Mode" line. The publish effect
now carries the whole snapshot and updates both it and the frontend, so
the two cannot disagree.

Rust tests 241 -> 249, covering the derived answers and that every
change — health, pending action, mode, and both edges of an in-flight
operation — is announced exactly once.

* refactor(core): decide TUN availability where the knowledge is

Turning TUN off when it stops being possible was a React effect: six
conditions, a ten-second startup grace period, a one-second cooldown ref
and a triple-negative predicate, guarding a write to the user's config
file — and none of it covered by a test, because until now the frontend
had no way to run one.

The grace period and the cooldown were both approximations of things the
backend actually knows. "Has the service been probed yet" is now
ServiceHealth::Unknown rather than a ten-second guess, and "is a disable
already being written" is a flag rather than a timer.

RunState::tun_should_be_disabled is the decision and is pure, so the
cases that must *not* trigger a write are pinned: an unprobed service,
an operation in flight, and a decision still sitting in front of the
user all mean not-yet-known. Writing the config is a separate concern in
feat::tun, because Run State owns state and not actions.

Behaviour is unchanged from the user's side: TUN is still switched off
in verge.yaml, with the same two notices, which now travel over the
existing notice channel as tun_mode::auto_disabled.

Worth knowing for review: this puts a config write on the Run State
transition path, and patch_verge's bitflag mapping has no tests of its
own. The write is guarded against the cascade it can trigger — patching
the config produces further transitions, each of which reconciles again
— and is spawned so a transition never waits on it.

One of the new tests caught a wrong assertion I had written: a ready
service mid-operation looked like "cannot work" but is really "not yet
known". That is the exact class of bug the fixed grace period was
papering over.

Rust tests 249 -> 256.

* refactor(config): name the two questions asked of the Mixed Port

Six call sites hand-copied "the user's selection, else the Merge Config"
and the frontend hand-copied a different chain that prefers what the Core
reports. Reading them as one duplicated rule is what made this look like
a straightforward dedup; they are actually two questions.

What have we configured? — needed by anything that writes config or
decides what to start, and answerable before the Core exists. What is the
Core serving on? — needed by anything pointing a client at the proxy,
because a startup fallback or a hand-edited Core config moves the port.
MixedPort::desired and MixedPort::effective say which is which, and each
call site now picks one on purpose.

Two defects fall out of naming them. feat::proxy built the proxy command
the user pastes into a shell from verge-or-a-hardcoded-7897, ignoring the
Merge Config entirely — it wanted effective(). And the six chains split
three-to-three between reading the committed and the draft config layer,
with feat::clash reading one of each, so mid-edit they disagreed; there
is one layer now.

effective() costs a round-trip, so it is used where a user triggered the
work, not on paths served per request. PAC stays on desired(): it answers
per browser request, and it is closed while the Core is between ports
anyway. Each site says which it chose and why.

The reserved-ports list in the startup fallback was a second hardcoded
listener table. It is derived from the first now, filtered to exclude the
Mixed Port itself — which is not a duplicate but a deliberate omission,
so it is stated as one rather than left implicit in a literal.

On the frontend the same four-source merge was computed twice, once in a
hook and once inline in the provider. The provider was mounting two
queries solely to recompute what the hook already had; both are gone.

Rust tests 256 -> 262.

* refactor(config): make a config change roll back by itself

Editing configuration meant pairing every edit_draft with an apply or a
discard on every exit path, by hand, across three independently
committed layers. The startup port fallback wrote the same three-line
rollback five times, save_proxy_ports called its rollback helper from
eight branches, and apply_config's nested match tree had six discards
and three applies. Nothing checked the pairing: a new early return that
forgot one left the app describing a configuration it was not in.

DraftTransaction commits a set of layers together and rolls them back
when dropped, so an early return — a ?, a bail!, a panic — cannot leave
a change half-applied. Committing is the explicit act, deliberately:
forgetting to commit costs a retry, forgetting to roll back corrupts
state.

The three call sites lose their hand-written rollback and gain ordinary
control flow. apply_config stops touching drafts entirely; whether the
staged Runtime Config survives now follows from whether applying it
worked, which is the caller's transaction to decide, and its nested
matches flatten out.

Two rollbacks stay explicit and are commented as to why. Files on disk
and a restarted core are not part of the transaction, so those paths
still restore snapshots themselves; and rollback_proxy_ports has to
clear the drafts *before* it regenerates files from the committed
config, which is earlier than drop would.

Draft discards drop from 14 sites to 8, applies from 18 to 15. The
crate gains 4 tests, including that an early return rolls back without
the caller saying so.

* refactor(proxy): state the delay sentinel rules once

A delay from the core is not just a latency: -2 means a test is running,
other negatives mean never tested, zero and anything past the timeout
mean it timed out, and absurd values mean error. Every surface agreed on
those rules and restated them, and the two sort comparators had already
drifted — a non-numeric delay sorted among the timeouts in the proxy
list and after the sentinels in the home card.

classifyDelay and compareByDelay say it once, with tests, including the
case the two copies disagreed on. The trap the ranks exist for is worth
naming: zero encodes a timeout, so sorting delays as plain numbers puts
the nodes that failed at the top of a fastest-first list.

Deliberately not unified: how a *measured* delay is graded. A
three-colour chip and a four-bar signal icon draw different
distinctions, and collapsing 400/250 into 500/300/200 would be inventing
a UI decision rather than removing a duplicated one. Those thresholds
stay where they are, next to a comment saying they are a presentation
choice. The report that prompted this called them "4 incompatible
threshold sets"; on reading them, the sentinel half was duplication and
the grading half was not.

ProxySortType was declared twice; it is imported now.

Frontend tests 8 -> 21.

* refactor(enhance): make the app's authoritative fields one value

The pipeline derives fields the app owns — ports, the external
controller, the tray-driven mode — then lets the user's merge and script
overrides run, then puts its own values back. That ordering is a
correctness requirement: capture must follow the app's own stages and
precede every override, restore must follow all of them. It was four
separate calls held together by four comments.

AuthoritativeFields captures and restores as one value, so the restore
cannot be forgotten, reordered ahead of the overrides, or applied to a
config the snapshot did not come from. Five tests state what it is for,
including the two cases that are easy to get backwards: an override must
not re-add a field the app deliberately left out, and reinstating
dns.ipv6 must not conjure a dns block an override removed.

merge_default_config now takes enable_external_controller instead of
reading it out of the global config from inside its loop. A stage that
looks like a function of its arguments and is not is worse than one that
admits it, and this is the change that makes it testable at all.

Not done here: reshaping the sixteen steps into a stage list. The value
in this pipeline is the nine steps with no tests, not the shape of the
function that calls them, and the two contracts above were the parts
that could silently break.

Rust tests 262 -> 267.

* refactor(proxy): make the proxy page's outcome a closed set

resolveProxyEmptyState returned `reason | 'loading' | null`, which lied
twice: 'loading' is not a reason and ProxyEmptyState cannot render it,
and `null` meant either "there is content" or "this is direct mode" —
so the caller decided direct mode a second time, before even asking.

resolveProxyListState returns one closed set of outcomes and the caller
switches on it. Ten tests pin the ordering that was previously implicit,
including that anything still loading outranks every verdict about
emptiness, and that global mode asks about GLOBAL rather than groups.

Known gap, left alone deliberately. In chain mode the model asks whether
any Selector or URLTest group exists, while the renderer builds its list
from selectRuleChainMembers or selectGlobalChainNodes — unrelated
questions, and both a false "empty" and a false "not empty" are
reachable. Fixing it means the model needs the selected group and the
runtime proxies, which is the fold of emptiness into use-render-list
that this candidate was really about; guessing at it from outside would
add a third derivation rather than remove the second.

Frontend tests 21 -> 30.

* refactor(events): one subscription with one teardown

Four places listened for backend events, three idioms between them, and
four separate implementations of the same cleanup. One of them —
app-data-provider — pushed each unlisten onto its cleanup list only
after awaiting registration, with no disposed flag, so a subscription
that resolved after unmount was never torn down. That is now handled in
one place, because registration being asynchronous is a property of the
mechanism rather than of any call site.

Event names were string literals on both sides of the IPC seam, so a
rename was a silent no-op. The frontend now declares each name once with
its payload type, which does not make Rust check itself but does replace
a per-call-site `listen<T>` guess with one place to look.

use-layout-events loses about fifty lines that were registration
plumbing rather than behaviour, and profiles.tsx loses a
Promise.allSettled that existed to collect unlisteners.

Left alone: window events — onResized, onFocusChanged,
WINDOW_CLOSE_REQUESTED. They come from Tauri's window API rather than
the app's own bus, and folding them in would widen the registry to
things it does not name.

* fix(ui): stop AppDataProvider consuming its own context

The provider rendered nothing and threw "useClashConfigData must be used
within AppDataProvider": a previous commit replaced its inline mixed-port
merge with useDisplayedMixedPort, and that hook reads the ClashConfig
context which this component is the one providing.

The provider resolves the port from its own sources again. The two
computations are not the duplicate they looked like — one runs inside the
provider and one outside it, and a hook that reads the provider's context
cannot run in both places. They share resolveDisplayedMixedPort, which is
the part that was ever worth sharing; a comment now says why the hook is
not used here.

Nothing else in the provider consumed its own context: it imports the
Context objects rather than the reader hooks, and useClashInfo and
useRuntimeConfig are plain queries.

* fix(core): repair regressions found by review

An independent review of this branch found several defects, two of them
introduced here and reachable on a normal first run.

Startup could silently turn TUN off. Service health is observed as
NotInstalled before the startup path decides whether to install the
service, and the TUN reconciler ran against that captured snapshot — so
it wrote enable_tun_mode: false to the user's config, and because
prepare_startup reads that same setting, the install prompt that would
have fixed the situation never appeared either. The deleted frontend
guard covered this with a ten-second grace period; nothing replaced it.
The reconciler now re-reads the current Run State instead of taking a
snapshot, and refuses to act at all while the Core is not running,
because until then no decision has been made to act on.

It also overrode a decision that had already been taken. Falling back to
Sidecar calls suppress_tun_for_session, which is deliberately not
persistent — disable_tun_and_persist exists as the separate explicit
act. The reconciler now honours the session suppression rather than
escalating it to a config write.

Reconciliation was additionally skipped whenever the app handle did not
exist yet, because it was spawned after that early return; it is spawned
before it now, which is safe since it no longer depends on the snapshot.

apply_service_status wrote the requested action into the store and read
it back to decide what to run. A concurrent observation clears the
pending action, which would have turned a user-authorised install into a
silent no-op. It derives the action from its own argument again, as the
code it replaced did.

save_proxy_ports opened its transaction before its first fallible read,
so that early return discarded drafts staged by other code that this
function never touched. It also left rollback to drop, which runs after
the recovery paths that regenerate files from committed config — and a
second time after those paths had already discarded. The transaction now
opens after the read and is rolled back explicitly where recovery needs
it to have happened already; the same ordering fix applies to the
startup port fallback. DraftTransaction gained a consuming rollback for
this.

Two pre-existing defects the review surfaced are fixed here because this
branch's code depends on them. patch_verge's `?` returned past its own
`if let Err(..) { discard() }`, which the compiler could never reach, so
a failed patch left the edit in the draft — and the TUN reconciler would
then read that draft, believe TUN was already off, and never retry.
settled() built its Notified future but never registered it before
checking the flag, so an operation finishing in that window was a wakeup
lost for good; the waiter would then block until some unrelated later
operation, or forever.

Deferred with reasons: cancellation-safety of the config futures (no
cancellation site demonstrated), enter_mode's PAC/mode atomicity
(pre-existing, narrow), and the draft-vs-committed reads behind
MixedPort::desired (the PAC window is now closed by core_starting at
staging time).

Rust tests 267 -> 268.

* docs(core): inline the boundary rationale into the code

The comments pointed at a glossary and an ADR for why Run State owns
state but not Core lifecycle. The reason is short enough to say in
place, and a reason that travels with the code it constrains is worth
more than a pointer to a file a reader has to go and find.

* test(ui): mount AppDataProvider so a context misuse fails loudly

A provider that reads a context it supplies throws during render and
blanks the whole UI. That shipped: AppDataProvider called
useDisplayedMixedPort, which reads ClashConfigContext. typecheck, lint,
knip and web:build all passed with it present, and so did an independent
three-reviewer code review — the frontend reviewer returned nothing on
it. Only rendering can see it.

The test stubs `invoke` and `listen` and nothing above them. Everything
the provider mounts — the SWR queries, the config hooks, the event
subscription, the mihomo plugin — goes through those two, so all the
real application code still runs. Stubbing the hooks instead would have
produced a test that could not catch what this exists to catch.

Verified by reintroducing the bug: both cases fail with the production
error, "useClashConfigData must be used within AppDataProvider". A smoke
test that passes either way is worth nothing, so this one was checked
against the defect rather than only against a working tree.

Render tests opt into jsdom per file, so the DOM cost is paid only where
it buys something; the rest of the suite stays on node. The libraries
land in this commit rather than ahead of it, because knip fails on a
dependency nothing uses yet.

Frontend tests 30 -> 32.

* refactor(proxy): let the list say whether it is empty

The empty-state model predicted "is there anything to render" beside the
renderer instead of asking it. In chain mode the two asked different
questions: the model looked for any Selector or URLTest group, while the
list is built from selectRuleChainMembers or selectGlobalChainNodes.
Both a false "empty" and a false "not empty" were reachable — GLOBAL
with standalone nodes but no Selector group showed nodes while the page
said no-proxy-info, and Selector groups with no global and no standalone
nodes said content over an empty list.

Emptiness is now an observation. hasRenderableItems reads the list that
is about to be drawn, and the two list components render the empty state
themselves when it comes back with nothing. resolveProxyListState keeps
only what is knowable without building a list — direct mode, still
loading, and having no usable subscription — which drops proxyView and
isChainMode from its input and leaves it with one job.

Two details worth stating rather than leaving to be rediscovered. A
group header counts as content only when the group is visible, matching
what the old predicate meant by "some group is not hidden". Rows inside
a hidden but expanded group now count, where the old predicate called
the page empty; the user can see those rows, so claiming otherwise was
a lie.

Frontend tests 32 -> 38, including the chain case that was wrong in both
directions.

* test(enhance): cover the config transforms that had none

Seven of the pipeline's steps had zero references from any test. Four of
them are pure functions of their arguments and are now covered; the
other three are not, and the difference is the point.

use_tun, ensure_fake_ip_range6, use_sort and merge_default_config decide
what ends up in the file the core reads, and each has behaviour that is
easy to get backwards and impossible to notice:

- a disabled listener has its port *removed*, not merely skipped, so the
  profile's own value cannot open a listener the user switched off
- a disabled external controller is blanked rather than deleted, because
  an absent key lets mihomo fall back to its default and listen anyway
- an empty fake-ip-range6 counts as missing, since YAML edited by hand
  leaves the key present with nothing after the colon
- a deliberate redir-host DNS setup is left alone; only fake-ip is ours
- turning TUN off does not rewrite DNS, only the system resolver
- sorting drops nothing, and is stable when applied twice

get_config_values and collect_profile_items are left uncovered on
purpose: their whole job is reading global config, so a test would
assert that a mock returns what the mock was given. apply_dns_settings
and apply_builtin_scripts read the filesystem and run JS respectively;
covering those means giving them a seam, which is a change to the code
rather than a test of it.

Rust tests 268 -> 289.

* refactor(proxy): re-sort when a delay test settles

Sorting a group by delay read the delay store inside a memo that had no
dependency on it, so the order only refreshed when something else forced
a recompute. Both places that sort by delay had their own way of forcing
it, and both hooked only the "test all" path: retesting a single proxy
updated that row's own number and left it sitting in the wrong place.

The fix is a subscription, but the granularity is the whole design. The
store already coalesces notifications onto an animation frame, so the
cost was never the render count — announcing per result would re-sort
continuously while a batch is still running, with rows moving out from
under the pointer. So the group listener now means "a test settled":
once per single test, once per batch however many proxies it covered.
Measured against the code before this, a 20-proxy batch would have
announced 21 times.

That granularity is the thing that normally cannot be asserted, because
its symptom is jumpy scrolling rather than a wrong value. Moving the
trigger into the store turns it into a number a test can count, and the
batch test was checked against the defect: with per-result announcements
restored it fails with 21 where it expects 1.

The hooks hand over a snapshot rather than a revision counter. A counter
has to be listed as a dependency the memo never reads, and
exhaustive-deps is right to reject that — the code this replaces
satisfied the rule with a `refreshTick >= 0 ? … : 0` ternary whose only
purpose was to make the counter look used. A snapshot makes sorting a
pure function of a value it is handed. Both invalidation hacks and that
ternary are gone.

Group listeners are a set now, each with its own unsubscribe. They were
one-per-group, so two views watching the same group would have silently
replaced each other.

Frontend tests 38 -> 43.

* fix(proxy): repair the delay reactivity found by review

A review of the previous commit found four defects, two of them worse
than the code they replaced.

The snapshot hook returned a new object on nearly every render. Its
state started keyed to a null group, and in the home card the group name
resolves after the first render, so the derived branch was taken until
some test settled — which, with auto-testing off, is never. The sort
memo therefore re-ran on every render and handed a new array downstream.
The counter it replaced was at least stable.

Both hooks also read during render while the store notifies from an
animation frame, so a settle landing between render and the passive
effect that subscribes was dropped with nothing to re-trigger it. Both
are now built on useSyncExternalStore, which is the mechanism for
exactly this and fixes the identity churn at the same time: the store
hands out a per-group handle whose identity changes only on settle.

A single test could announce while a batch was still running. The
per-row delay button and "test all" hold separate locks, so a user can
start one inside the other, and sorting from a half-measured group is
precisely the reordering this design exists to prevent. Batches are now
counted per group and a settle inside one is suppressed until it exits —
in a finally, so a throw cannot leave the group unannounced with its
proxies pinned at "testing" and nothing later to repair the order.

One settle also invalidated every group's cached order, because all
groups shared a single token. Each group carries its own handle now, so
a test in one group no longer makes its neighbours re-sort.

Both new behaviours were checked against their absence: removing the
batch counter fails the coordination test, and the earlier granularity
test still fails with 21 where it expects 1. The review's other three
findings are left alone with reasons — TTL expiry mutating during a read
is pre-existing and needs 30 idle minutes, NUL cannot appear in a group
name from mihomo's config, and the batch-count assertion already rules
out mid-flight fires because a 500ms floor per proxy prevents them
collapsing into one frame.

Frontend tests 43 -> 45.

* feat(core): reload the core in place when switching profiles in Service mode

Switching a profile in Service mode stopped and restarted the core, roughly 1.2s
of dropped traffic every time. This is pre-existing, from #7569 rather than from
this branch: before it, apply_config had no Service branch at all.

Two independent reasons, both now addressed. The service protocol had no verb
for "change the configuration" other than stop plus start, so a companion change
adds `/clash/stage-runtime`. And config reaches the core differently in the two
modes — in Sidecar the core reads the app's own directory, in Service it reads a
directory the service materialised, with the provider paths rewritten to match.
That asymmetry, not session ownership, is why the else-branch could never simply
be shared.

apply_config now has one shape for both modes: put the configuration where the
core can read it, ask the core to reload, replace the core if that did not work.
Only the first step differs. Service mode asks the service to stage the runtime
in place and, if it did, points the core at the service's copy over the mihomo
IPC the app already has open — the app has always been able to command the core
in Service mode; only materialising the config needed the service.

Every way of not staging leads back to exactly what happened before: a service
too old to stage, a service that declined, a request that never came back, or a
reload the core refused. `plan_config_application` is the whole of that decision
and is a pure function of the attempt, so all four are unit tests rather than
things you can only observe against a live service. Verified against the defects
they target: treating a refusal as reloadable, and dropping the provider url
during collection, both make them fail.

The capability is learned once when the core starts and is held with the owner
session rather than cached globally. It describes the service instance that owns
this core, which is the only scope where acting on it is safe — a service
upgraded underneath a running core does not silently gain abilities the core it
owns was not started under. A failed protocol query reads as "no", because the
only cost is the fast path.

`collect_runtime_bundle` now also declares remote providers. It is not an
optimisation: the core will not re-fetch a provider whose file already exists, so
a download cache left from a different url keeps being served after the switch.
The service can only tell a reusable cache from a stale one if the bundle says
which url produced it.

Not done, and it gates pushing: the dependency pin still names v2.5.3, because
v2.6.0 does not exist yet. `^2.5.3` admits 2.6.0 and a local `[patch]` in
~/.cargo/config.toml supplies it, so this builds and tests here but not anywhere
else. Cargo.lock is deliberately not in this commit — it currently records a
filesystem path. Both need a follow-up once the service-ipc branch is pushed.

Nothing here has been run in the real app. The fast path has been exercised
against a real service and a real child process in the companion repository's
integration tests, and mihomo's reload behaviour was measured directly before
any of it was designed, but a `pnpm dev` profile switch in Service mode has not
happened.

* fix(core): keep a working core when the service refuses the bundle

Follow-up to the review of the staging change, on the app side.

Every service answer was funnelled into the same fallback. A service that looked at
the bundle and refused it — an asset that is gone, a destination it will not accept,
a session we no longer own — is telling us something deterministic: the fresh start
that follows materialises the same bundle and fails the same way, so stopping the
running core adds an outage to a failure that has already happened. `StageAttempt`
now distinguishes a refusal from silence, and a refusal leaves the core running and
reports why. Silence still replaces the core, because not hearing back says nothing
about whether starting would work.

This was not a regression — before staging existed the core was stopped in these
cases too — but the information to do better only exists now, and throwing it away
was the wrong default.

`collect_provider_assets` decided a provider was remote by whether it carried a
`url`. The core decides by `type`, so the two disagreed: a `file` provider with a
stray `url` was treated as remote, its local file left uncopied, and the core could
then not find what the rewritten path pointed at. It now reads `type`, and a `http`
provider without a usable url is an error rather than a silent reclassification.

Remote destinations now claim their slot in the same set local ones do. A path
claimed by both a file the service copies and a file the core downloads is refused
here rather than reaching the service, which refuses it as well — the copy would
otherwise win and both records would match from then on, pinning the wrong content
for the life of the generation.

Verified by reintroducing each defect: treating a refusal as a restart, and losing
the provider url during collection, both make their tests fail.

* fix(core): only treat a bundle refusal as a reason not to restart

Verification found the previous commit had over-applied its own idea, and the
mistake mattered.

It routed *every* non-zero service response to "leave the core running and report
the error", on the premise that a fresh start would be refused the same way. That
premise holds only for refusals about the bundle. `StageRuntime` also answers with
a code for a protocol mismatch, a malformed request, an unauthorised owner, and a
stale owner session — and for a stale session the premise is exactly backwards:
`start_clash` proposes a new session rather than presenting the old one, so
starting is how that gets resolved. Worse, the restart path is what notices
ownership was lost: `stop_core_by_service` clears the proxy and PAC and marks the
core stopped when the session is stale, and skipping it left that undone until the
owner monitor came round two seconds later. Only `InvalidRuntimeAsset` and
`InvalidInstallLocation` now leave the core alone; everything else replaces it, as
before.

`collect_provider_assets` was refusing two providers that name the same file *and*
the same url. The service folds that — it is one file with two names for it, not a
conflict — and mihomo accepts such a config; measured, not assumed. Since this
function is also where a Service-mode *start* gets its bundle, the refusal stopped
the core coming up at all. It now compares urls and refuses only genuine
disagreement.

Deciding remote by `type == "http"` was right, but failing the whole bundle when
that classification does not fit was not. Measured against mihomo v1.19.26: it
accepts a `http` provider with no url, an `inline` provider carrying both a url and
a path that does not exist, and a `file` provider whose path is missing. Each of
those had become "this configuration cannot be applied at all", where before it
merely behaved oddly. A provider that cannot be classified now keeps the path the
core was given, is not declared to the service, and is logged.

Each correction has a test, and the error-code split is pinned by one that walks
the codes rather than trusting a message.

* build(deps): resolve the service protocol crate from its pushed branch

The staging work needed protocol revision 2, which did not exist anywhere yet, so
the last three commits built against a local checkout supplied by an untracked
`[patch]` and deliberately left `Cargo.lock` out. That branch is pushed now, so the
pin names it and the lock records the commit rather than a path on one machine —
this tree builds for anyone again.

Still a branch and not a tag: the protocol has only been exercised by the service's
own tests and by this client compiling against it, never by the two running
together. Moving to v2.6.0 is worth doing once it has.

* build(deps): take the kode-bridge listener-mode fix

Only reaches this repository through the service protocol crate, but the lock is
where the commit is recorded, so it has to move here too.

kode-bridge applied a listener's permission mode as a bind-time option on every
unix except macOS, where the umask filters it — a service asking for 0o666 got
0o644 or 0o600 depending on the host. That is why the service published a control
socket on Linux that no unprivileged client could open.

* build(deps): advance the service protocol pin to the branch head

The lock still named the commit the branch had when it was first pinned, five
commits ago; two of those are the Windows CI fixes. Nothing in them changes what
this repository compiles — the parts they touch are behind the `test` feature,
which this dependency does not enable — but a lock that names a commit the branch
has moved past means the next person to rebuild gets something different from what
was tested here.

Only the commit changes. `cargo update -p clash_verge_service_ipc` also rewrites
twelve unrelated crates from `windows-sys 0.61.2` down to 0.60.2 and 0.52.0, which
has nothing to do with this pin and is not wanted; `cargo check --locked` confirms
the 0.61.2 resolution is still valid, so the downgrade is the resolver's preference
rather than a requirement.

* fix(ci): unblock Windows Rust checks
2026-07-27 00:44:53 -07:00
Tunglies 5a2f1fc4c7 fix(macos): sync tray speed style across displays 2026-07-27 14:28:29 +08:00
wonfen 389c5342e5 fix(service): harden transient IPC handling 2026-07-26 06:31:32 +08:00
AnnAngelaandCopilot Autofix powered by AI df66d976a5 feat(updater): skip installer language prompt during auto-update (#7572)
* feat(updater): skip installer language prompt during auto-update

* fix: Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-26 02:02:02 +08:00
Evan 85b5501faf fix(profile): prevent crash when editing proxies with empty name (#7582)
When a proxy node without a name (name: null) exists in the prepend/append
sequence, ProxiesEditorViewer passed a null id into @dnd-kit's SortableContext,
which runs `'id' in item` on it and throws
"Cannot use 'in' operator to search for 'id' in null", crashing the page.

Filter out proxies without a valid (non-empty string) name from the rendered
and sortable lists so the visualization editor no longer crashes. The raw YAML
data is preserved and can still be fixed in the advanced (text) mode.

Fixes #7580
2026-07-26 01:35:37 +08:00
wonfen 2fc831de27 chore: remove promotion 2026-07-26 01:29:44 +08:00
wonfen ece3c71f38 feat(proxies): add empty-state troubleshooting guidance 2026-07-26 01:28:49 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f86a591597 chore(deps): update github/gh-aw-actions action to v0.83.3 (#7591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 12:56:28 +00:00
wonfen e599a9d02c fix(service): avoid sticky availability probes 2026-07-25 15:22:46 +08:00
wonfen 7796259b03 feat: automatic mixed port fallback (#7579)
* feat: automatically select an available mixed proxy port

* fix: coordinate listener port changes

* fix: detect Windows listener port conflicts
2026-07-25 05:47:28 +08:00
wonfen 91d0643b5c chore: update service-ipc version 2026-07-25 03:34:48 +08:00
Sam Snowman(赵正中) 52bbf07f1f Update Chinese language label to Simplified Chinese (#7575) 2026-07-24 14:14:22 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 4e9524898d chore(deps): update github/gh-aw-actions action to v0.83.2 (#7586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 12:24:34 +00:00
wonfen bce83d5c6f chore: update i18n 2026-07-24 11:53:50 +08:00
wonfen 82c486923b fix(CI): tg notify error 2026-07-24 09:33:35 +08:00
wonfen 23dda47641 feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime

* ci(windows): run owner security tests

* chore(deps): use kode-bridge v0.4.1 tag

* fix(macos): classify installed service state

* fix(macos): classify marked unavailable service

* fix(macos): gate core startup on service migration

* fix(macos): scope service init to supported platforms

* feat(macos): prompt before migrating installed service

* fix(macos): shorten sidecar IPC path

* build(service): pin helper protocol and artifacts

* fix(core): require confirmed service and sidecar readiness

* fix(macos): harden launch roots and singleton state

* fix(ui): refresh migration state before restart

* fix(macos): linearize service migration lifecycle

* fix(ui): keep service migration state fresh

* feat(service): track authenticated owner sessions

* fix(service): preserve newer owner sessions

* feat(macos): route service proxy changes through helper

* fix(macos): serialize service proxy guard

* fix(macos): close service proxy owner lifecycle

* fix(macos): close remaining proxy stop races

* fix(app): do not cancel controlled service stop

* fix(lifecycle): keep controlled updates cancellation-safe

* fix(macos): stage dev core for service

* test(macos): exercise staged core cleanup

* fix(dev): await controlled macOS exit

* chore: update changelog

* fix(macos): recover sidecar after service install failure

Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state.

* fix(macos): stage dev service tools before elevation

* fix(macos): localize launch guard prompts

* fix(service): address cross-platform integration issues

* feat: support seamless service migration

* fix: pass Windows service installer path safely

* fix(prebuild): derive service release from Cargo dependency

* fix(dev): preserve service installation state

* fix: resolve clippy warnings in service lifecycle
2026-07-24 09:01:49 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> c079da6c99 chore(deps): update github/gh-aw-actions action to v0.83.1 (#7574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 14:01:53 +00:00
Tunglies e9cd49eb46 ci(frontend): enforce knip checks on pull requests 2026-07-23 15:29:31 +08:00
Tunglies 0a19dd965d fix(frontend): resolve knip report findings 2026-07-23 15:28:47 +08:00
Tunglies 444215f8ed refactor(frontend): simplify home card settings state 2026-07-23 15:25:09 +08:00
Tunglies 17fe92352e refactor(frontend): centralize tun availability guard 2026-07-23 15:07:33 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 2c8b1a0607 chore(deps): update actions/setup-node action to v7 (#7481)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 01:59:56 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b08bcfedb8 chore(deps): update github actions (#7437)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 01:31:56 +00:00
oomeow ccf21f4a31 fix(config): apply runtime configuration on initialization (#7562) 2026-07-22 14:19:05 +00:00
oomeow b57668243b fix(proxy): use latest runtime instead of committed runtime for proxy view (#7560) 2026-07-22 12:53:38 +00:00
wonfen 6219452168 chore: update changelog 2026-07-21 12:13:46 +08:00
wonfen 304f5bade9 perf(proxy): consolidate proxy data into a single IPC (#7542)
* feat(proxy): build deterministic proxy view

* feat(proxy): expose ordered proxy view command

* feat(proxy): add proxy view client contract

* fix(lint): allow Node test files

* feat(proxy): switch UI to ordered proxy view

* fix(proxy): harden ordered proxy view consumers

* fix(proxy): compare chain duplicates by current id

* fix(proxy): release mihomo guard after fetch

* fix(proxy): preserve semantic selections across refresh

* fix(proxy): block global chain without GLOBAL

* fix(proxy): handle proxy view edge states
2026-07-21 00:11:59 +08:00
wonfen fd70d4c62b fix(enhance): make GUI TUN config authoritative 2026-07-20 12:33:47 +08:00
Tunglies d7dbff9f08 chore(hooks): block pushes on knip errors 2026-07-20 02:52:04 +08:00
Tunglies c85928ecaf fix(i18n): keep language section loader private 2026-07-20 02:50:49 +08:00
Tunglies e63b8e6eb0 perf(frontend): eliminate navigation loading states 2026-07-20 02:43:38 +08:00
Tunglies 971dd37965 chore: bump version 2.5.3 2026-07-20 00:24:35 +08:00
297 changed files with 25992 additions and 5769 deletions
+3 -3
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.74.8": {
"github/gh-aw-actions/setup@v0.83.4": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.74.8",
"sha": "efa55847f72aadb03490d955263ff911bf758700"
"version": "v0.83.4",
"sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
},
"github/gh-aw/actions/setup@v0.74.8": {
"repo": "github/gh-aw/actions/setup",
+7 -7
View File
@@ -46,7 +46,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -151,7 +151,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.96.1'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -180,7 +180,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
cache: 'pnpm'
@@ -256,7 +256,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.96.1'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -279,7 +279,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
cache: 'pnpm'
@@ -424,7 +424,7 @@ jobs:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
cache: 'pnpm'
@@ -526,7 +526,7 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
- name: Install Node
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
cache: 'pnpm'
+7 -3
View File
@@ -48,7 +48,7 @@ jobs:
with:
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.18.0'
@@ -78,6 +78,10 @@ jobs:
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck
- name: Report unused frontend code and dependencies
- name: Run tests
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:report
run: pnpm test
- name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
+5
View File
@@ -78,6 +78,11 @@ jobs:
working-directory: ./src-tauri
run: cargo clippy-all
- name: Run Windows security tests
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }}
- name: Run Logging Check
working-directory: ./src-tauri
shell: bash
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -205,7 +205,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.96.1'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -229,7 +229,7 @@ jobs:
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -313,7 +313,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.96.1'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -449,7 +449,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.96.1'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -467,7 +467,7 @@ jobs:
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -554,7 +554,7 @@ jobs:
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -580,7 +580,7 @@ jobs:
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -642,7 +642,7 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.18.0'
Generated
+352 -209
View File
File diff suppressed because it is too large Load Diff
+11 -6
View File
@@ -56,9 +56,9 @@ tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sys
tauri = { version = "2.11.5" }
tauri-plugin-clipboard-manager = "2.3.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.103"
anyhow = "1.0.104"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.52.3", features = [
tokio = { version = "1.53.1", features = [
"rt-multi-thread",
"macros",
"time",
@@ -68,17 +68,22 @@ flexi_logger = "0.31.9"
log = "0.4.33"
smartstring = { version = "1.0.1" }
compact_str = { version = "0.9.1", features = ["serde"] }
compact_str = { version = "0.10.0", features = ["serde"] }
serde = { version = "1.0.228" }
serde_json = { version = "1.0.150" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.0" }
bitflags = { version = "2.13.1" }
socket2 = "0.6.5"
# *** For Windows platform only ***
deelevate = "0.2.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_JobObjects"
] }
+14 -68
View File
@@ -1,83 +1,29 @@
## v2.5.2
## v2.5.3
### 🐞 修复问题
- 修复 macOS 托盘速率显示样式异常
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
- 修复前端连接页面导致的内存泄漏
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
- 修复首页代理卡片可能错误显示通信异常的问题
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
- 修复 DNS 覆写的高级模式无法正常编辑
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
- 修复 Linux 应用内更新问题
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
- 修复全局热键开关设置可能被意外覆盖的问题
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
- 修复 macOS 托盘图标可能闪烁的问题
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
- 修复首页上传和下载总流量统计不准确的问题
- 修复首页当前代理状态偶尔不同步的问题
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
- 修复切换代理模式失败时未正确显示错误的问题
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
- 修复全局快捷键隐藏主窗口行为异常的问题
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
- 修复日志刷新时页面可能被强制滚动到底部的问题
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
- 修复代理组筛选后定位及滚动位置恢复异常的问题
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
- 修复内核启动慢导致首页上下跳动的问题
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
修复 macOS 多显示器切换时托盘速率样式不同步
修复 WebSocket 在 onConnected 初始化完成前被保存的问题,避免初始化失败后留下无监听且无法重试的无效连接。
修复 Mihomo API 变更后 inline proxy-provider 被错误过滤的问题,解决节点显示 Unknown 及延迟测试失败的问题
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- **Mihomo(Meta) 内核升级至 v1.19.29**
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
- 全局扩展脚本增加恢复默认按钮
- DNS 添加 fake-ip-range6 可配置项
- Linux 无边框窗口支持拖拽调整大小
- 代理组新增筛选、排序、延迟测试和快速定位工具
- 首页当前节点过滤已隐藏的代理组
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 更健壮的服务生命周期管理
- 更健壮的 Mihomo API 通信机制
- 关闭 autofill 弹出窗口
- 改进切换订阅后激活选中节点的逻辑
- 实现代理组粘性滚动列表
- 完善了配置覆写的相关逻辑
- 支持 Provider 节点延迟检测
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
- 重构改进订阅配置切换逻辑
- 优化大量订阅卡片时的拖拽排序性能
</details>
<details>
<summary><strong> 👙 界面样式 </strong></summary>
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
- 优化代理组工具栏和筛选框布局
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
- 优化监听端口冲突检测:代理端口按最终 Runtime 监听范围校验并协调三层持久化,隧道按实际地址与 TCP/UDP 协议检测且保留热重载
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
- 统一系统代理所有权,实现原子化代理切换
- 新增旧版 Service 迁移引导与 Sidecar 安全回退
- 优化核心启动、停止及更新流程,减少竞态与残留进程
- 优化首页当前节点的交互
</details>
+20 -6
View File
@@ -25,7 +25,8 @@ command = "pnpm"
args = ["typecheck"]
[tasks.typecheck.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "typecheck"]
[tasks.knip-report]
description = "Report unused frontend code and dependencies"
@@ -33,7 +34,17 @@ command = "pnpm"
args = ["knip:report"]
[tasks.knip-report.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "knip:report"]
[tasks.knip-check]
description = "Fail on unused frontend code and dependencies"
command = "pnpm"
args = ["knip:check"]
[tasks.knip-check.windows]
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "knip:check"]
[tasks.lint-staged]
description = "Run lint-staged for staged files"
@@ -41,7 +52,8 @@ command = "pnpm"
args = ["exec", "lint-staged"]
[tasks.lint-staged.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "exec", "lint-staged"]
[tasks.i18n-format]
description = "Format i18n keys"
@@ -49,7 +61,8 @@ command = "pnpm"
args = ["i18n:format"]
[tasks.i18n-format.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "i18n:format"]
[tasks.i18n-types]
description = "Generate i18n key types"
@@ -57,7 +70,8 @@ command = "pnpm"
args = ["i18n:types"]
[tasks.i18n-types.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "i18n:types"]
[tasks.git-add]
description = "Add generated files back to git index"
@@ -126,6 +140,6 @@ end
has_frontend = exec git diff @{u}..HEAD --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
if eq ${has_frontend.code} 1
cm_run_task typecheck
cm_run_task knip-report
cm_run_task knip-check
end
'''
+7 -5
View File
@@ -107,6 +107,12 @@ pnpm run prebuild
pnpm dev
```
`pnpm dev` preserves the Development Channel's installed service state: an
existing service is used, while a previously uninstalled service remains
uninstalled and the app starts in Sidecar mode. Use `pnpm dev:service` to
explicitly install or update the isolated development service before launch,
or `pnpm dev:sidecar` to force the unprivileged Sidecar workflow.
## Contributions
Issue and PR welcome!
@@ -124,8 +130,4 @@ Clash Verge rev was based on or inspired by these projects and so on:
## License
GPL-3.0 License. See [License here](./LICENSE) for details.
<p align="right">
<sub><a href="https://sponsorship.forztn.com/github/clash-verge-rev/clash-verge-rev">Supported By ForZTN</a></sub>
</p>
GPL-3.0 License. See [License here](./LICENSE) for details.
+4 -1
View File
@@ -10,7 +10,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"preset": "recommended",
"suspicious": {
"noExplicitAny": "off"
}
}
},
"formatter": {
+137
View File
@@ -17,6 +17,8 @@ struct DraftInner<T> {
data: Mutex<DraftData<T>>,
data_modifying: AtomicBool,
data_modify_notify: Notify,
/// Held by the one [`DraftTransaction`] currently allowed to stage a change.
claimed: AtomicBool,
}
struct DataModifyPermit<'a>(&'a AtomicBool, &'a Notify);
@@ -42,6 +44,7 @@ impl<T: Clone> Draft<T> {
data: Mutex::new((Arc::new(Box::new(data)), None)),
data_modifying: AtomicBool::new(false),
data_modify_notify: Notify::new(),
claimed: AtomicBool::new(false),
}),
}
}
@@ -145,3 +148,137 @@ impl<T: Clone> Draft<T> {
}
}
}
/// A draft layer that can be claimed, committed or rolled back without knowing what it holds.
///
/// Exists so a change spanning several differently-typed layers can be treated as one unit.
pub trait DraftLayer: Send + Sync {
/// Take exclusive staging rights, or report that someone else holds them.
fn try_claim(&self) -> bool;
fn release(&self);
fn apply_draft(&self);
fn discard_draft(&self);
/// Names the layer in a [`DraftBusy`] error. Only ever read by humans.
fn layer_name(&self) -> &'static str;
}
impl<T: Clone + Send + Sync> DraftLayer for Draft<T> {
#[inline]
fn try_claim(&self) -> bool {
self.inner
.claimed
.compare_exchange(false, true, Acquire, Relaxed)
.is_ok()
}
#[inline]
fn release(&self) {
self.inner.claimed.store(false, Release);
}
#[inline]
fn apply_draft(&self) {
self.apply();
}
#[inline]
fn discard_draft(&self) {
self.discard();
}
#[inline]
fn layer_name(&self) -> &'static str {
std::any::type_name::<T>()
}
}
/// Another transaction is already staging a change to one of the layers asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftBusy {
/// The layer that was already claimed.
pub layer: &'static str,
}
impl std::fmt::Display for DraftBusy {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"configuration layer {} is being changed elsewhere",
self.layer
)
}
}
impl std::error::Error for DraftBusy {}
/// Commits a set of draft layers together, or rolls them all back.
///
/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a
/// change half-applied across layers. Committing is the explicit act, because forgetting to
/// commit costs a retry while forgetting to roll back leaves the app describing a state it is
/// not in.
///
/// Beginning one takes exclusive staging rights over every layer, and overlapping with another
/// transaction is refused rather than allowed to interleave. That is not caution, it is the
/// only honest option: a layer holds exactly one draft, so a second writer's `edit_draft`
/// overwrites the first writer's staged value *in place*. There is no version of "roll back
/// only my edit" that could be implemented — rolling back would discard a change the other
/// writer is about to commit, and it did: the other writer's `apply` then found no draft, did
/// nothing at all, and still reported success while its side effect had already happened.
#[must_use = "a transaction that is dropped without committing rolls back"]
pub struct DraftTransaction<'a> {
layers: Vec<&'a dyn DraftLayer>,
committed: bool,
}
impl<'a> DraftTransaction<'a> {
/// Claim `layers` and begin a transaction over them; they are committed in the order given.
///
/// Fails if any layer is already claimed, having released the ones taken so far, so a
/// refused attempt leaves nothing held.
pub fn begin(layers: Vec<&'a dyn DraftLayer>) -> Result<Self, DraftBusy> {
for (index, layer) in layers.iter().enumerate() {
if layer.try_claim() {
continue;
}
for claimed in &layers[..index] {
claimed.release();
}
return Err(DraftBusy {
layer: layer.layer_name(),
});
}
Ok(Self {
layers,
committed: false,
})
}
/// Commit every layer. Consumes the transaction so it cannot be committed twice.
pub fn commit(mut self) {
for layer in &self.layers {
layer.apply_draft();
}
self.committed = true;
}
/// Roll every layer back now, rather than whenever this value happens to drop.
///
/// Needed where recovery does more than forget the drafts — restoring files, or
/// regenerating them from the committed configuration. Those read the layers, so the
/// rollback has to have already happened, not be pending on a later drop.
pub fn rollback(self) {
drop(self);
}
}
impl Drop for DraftTransaction<'_> {
fn drop(&mut self) {
for layer in &self.layers {
if !self.committed {
layer.discard_draft();
}
layer.release();
}
}
}
+142
View File
@@ -303,3 +303,145 @@ mod tests {
assert_eq!(latest.enable_tun_mode, Some(false));
}
}
mod transactions {
use clash_verge_draft::{Draft, DraftTransaction};
fn begin<'a>(layers: Vec<&'a dyn clash_verge_draft::DraftLayer>) -> DraftTransaction<'a> {
DraftTransaction::begin(layers).expect("no other transaction should hold these layers")
}
#[test]
fn committing_applies_every_layer() {
let first = Draft::new(1_u8);
let second = Draft::new("a".to_owned());
let transaction = begin(vec![&first, &second]);
first.edit_draft(|value| *value = 2);
second.edit_draft(|value| *value = "b".to_owned());
transaction.commit();
assert_eq!(**first.data_arc(), 2);
assert_eq!(**second.data_arc(), "b");
}
#[test]
fn dropping_without_committing_rolls_every_layer_back() {
let first = Draft::new(1_u8);
let second = Draft::new("a".to_owned());
{
let _transaction = begin(vec![&first, &second]);
first.edit_draft(|value| *value = 2);
second.edit_draft(|value| *value = "b".to_owned());
}
assert_eq!(**first.data_arc(), 1);
assert_eq!(**second.data_arc(), "a");
assert_eq!(
**first.latest_arc(),
1,
"the draft itself is gone, not just uncommitted"
);
}
#[test]
fn an_early_return_rolls_back_without_the_caller_saying_so() {
fn attempt(draft: &Draft<u8>, succeed: bool) -> Result<(), &'static str> {
let transaction = begin(vec![draft]);
draft.edit_draft(|value| *value = 9);
if !succeed {
return Err("failed after editing");
}
transaction.commit();
Ok(())
}
let draft = Draft::new(1_u8);
assert!(attempt(&draft, false).is_err());
assert_eq!(**draft.data_arc(), 1, "a failed attempt leaves nothing behind");
assert!(attempt(&draft, true).is_ok());
assert_eq!(**draft.data_arc(), 9);
}
#[test]
fn a_partly_edited_transaction_still_rolls_back_whole() {
// The layer nobody edited must not be left holding a stale draft either.
let edited = Draft::new(1_u8);
let untouched = Draft::new(5_u8);
drop(begin(vec![&edited, &untouched]));
edited.edit_draft(|value| *value = 2);
{
let _transaction = begin(vec![&edited, &untouched]);
}
assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too");
assert_eq!(**untouched.latest_arc(), 5);
}
}
mod transaction_exclusivity {
use clash_verge_draft::{Draft, DraftTransaction};
#[test]
fn a_second_transaction_over_a_claimed_layer_is_refused() {
let draft = Draft::new(1_u8);
let _held = DraftTransaction::begin(vec![&draft]).expect("the first claim should succeed");
let refused = DraftTransaction::begin(vec![&draft]);
assert!(refused.is_err(), "a layer may only be staged by one transaction");
}
#[test]
fn a_refused_transaction_leaves_the_layers_it_did_claim_free() {
// Claiming is all-or-nothing: a caller that failed on the third layer must not keep
// the first two, or the next attempt would be refused for a transaction that no
// longer exists.
let first = Draft::new(1_u8);
let second = Draft::new(2_u8);
let _contended = DraftTransaction::begin(vec![&second]).expect("the first claim should succeed");
assert!(DraftTransaction::begin(vec![&first, &second]).is_err());
drop(DraftTransaction::begin(vec![&first]).expect("the layer that was rolled off must be free"));
}
#[test]
fn a_layer_is_free_again_once_its_transaction_ends() {
let draft = Draft::new(1_u8);
drop(DraftTransaction::begin(vec![&draft]).expect("first"));
let committed = DraftTransaction::begin(vec![&draft]).expect("free after a rollback");
committed.commit();
drop(DraftTransaction::begin(vec![&draft]).expect("free after a commit"));
}
#[test]
fn an_overlapping_writer_can_no_longer_discard_a_staged_edit() {
// The regression this pins. A holds a transaction across a slow side effect; B starts,
// stages its own edit over the one draft slot, fails, and rolls back. Before layers
// were claimed, B's rollback cleared the single draft, A's commit then found nothing to
// apply and did nothing at all — while A's caller was told it had succeeded and A's own
// side effect had already happened.
let verge = Draft::new(String::from("committed"));
let transaction_a = DraftTransaction::begin(vec![&verge]).expect("A claims the layer");
verge.edit_draft(|value| *value = String::from("A-wants-this"));
let transaction_b = DraftTransaction::begin(vec![&verge]);
assert!(transaction_b.is_err(), "B must be refused rather than share the slot");
transaction_a.commit();
assert_eq!(
**verge.data_arc(),
"A-wants-this",
"A's edit must survive an overlapping writer"
);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "4.1.0"
rust-i18n = "4.2.1"
sys-locale = "0.3.2"
[lints]
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
translocated: يشغّل macOS هذا التطبيق من مسار App Translocation مؤقت. انقله يدويًا في Finder إلى /Applications أو ~/Applications، ثم افتحه مجددًا.
rejected: |-
يجب تشغيل هذا التطبيق من مجلد Applications.
{reason}
notInBundle: الملف التنفيذي ليس داخل حزمة تطبيق.
canonicalizeBundleError: 'تعذر تحديد المسار الفعلي لحزمة التطبيق: {error}'
bundleNameError: تعذر تحديد اسم حزمة التطبيق. سيتم إنهاء التطبيق.
replacePrompt: يوجد تطبيق بالفعل في {destination}. هل تريد استبداله وإعادة تشغيله من Applications؟
movePrompt: لتشغيل خدمة الخلفية بأمان، يجب نقل التطبيق إلى {destination}. هل تريد نقله وإعادة تشغيله الآن؟
createApplicationsDirError: 'تعذر إنشاء مجلد Applications: {error}'
moveFailed: فشل نقل التطبيق. انقله يدويًا في Finder ثم أعد المحاولة.
replaceFailed: 'تعذر استبدال التطبيق الموجود بأمان: {error}'
relaunchFailed: تم نسخ التطبيق، ولكن تعذرت إعادة تشغيله تلقائيًا. افتحه يدويًا من Applications.
cancel: إلغاء
move: نقل
ok: موافق
tray:
dashboard: لوحة التحكم
ruleMode: وضع القواعد
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
translocated: macOS führt diese Anwendung aus einem temporären App-Translocation-Pfad aus. Verschieben Sie sie im Finder manuell nach /Applications oder ~/Applications und öffnen Sie sie anschließend erneut.
rejected: |-
Diese Anwendung muss aus einem Applications-Ordner gestartet werden.
{reason}
notInBundle: Die ausführbare Datei befindet sich nicht in einem Anwendungspaket.
canonicalizeBundleError: 'Der Pfad des Anwendungspakets konnte nicht aufgelöst werden: {error}'
bundleNameError: Der Name des Anwendungspakets konnte nicht ermittelt werden. Die Anwendung wird beendet.
replacePrompt: Unter {destination} ist bereits eine Anwendung vorhanden. Soll sie ersetzt und aus Applications neu gestartet werden?
movePrompt: Damit der Hintergrunddienst sicher gestartet werden kann, muss die Anwendung nach {destination} verschoben werden. Jetzt verschieben und neu starten?
createApplicationsDirError: 'Der Applications-Ordner konnte nicht erstellt werden: {error}'
moveFailed: Die Anwendung konnte nicht verschoben werden. Verschieben Sie sie im Finder manuell und versuchen Sie es erneut.
replaceFailed: 'Die vorhandene Anwendung konnte nicht sicher ersetzt werden: {error}'
relaunchFailed: Die Anwendung wurde kopiert, konnte aber nicht automatisch neu gestartet werden. Öffnen Sie sie manuell aus Applications.
cancel: Abbrechen
move: Verschieben
ok: OK
tray:
dashboard: Übersicht
ruleMode: Regelmodus
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
translocated: macOS is running this application from a temporary App Translocation path. In Finder, move it manually to /Applications or ~/Applications, then reopen it.
rejected: |-
This application must be launched from an Applications directory.
{reason}
notInBundle: The executable is not inside an application bundle.
canonicalizeBundleError: 'Unable to resolve the application bundle path: {error}'
bundleNameError: Unable to determine the application bundle name. The application will exit.
replacePrompt: An application already exists at {destination}. Replace it and relaunch from Applications?
movePrompt: To start the background service safely, the application must be moved to {destination}. Move and relaunch it now?
createApplicationsDirError: 'Unable to create the Applications directory: {error}'
moveFailed: Failed to move the application. Move it manually in Finder and try again.
replaceFailed: 'Unable to replace the existing application safely: {error}'
relaunchFailed: The application was copied, but it could not be relaunched automatically. Open it manually from Applications.
cancel: Cancel
move: Move
ok: OK
tray:
dashboard: Dashboard
ruleMode: Rule Mode
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
translocated: macOS está ejecutando esta aplicación desde una ruta temporal de App Translocation. Muévela manualmente en Finder a /Applications o ~/Applications y vuelve a abrirla.
rejected: |-
Esta aplicación debe iniciarse desde una carpeta Applications.
{reason}
notInBundle: El archivo ejecutable no está dentro de un paquete de aplicación.
canonicalizeBundleError: 'No se pudo resolver la ruta del paquete de la aplicación: {error}'
bundleNameError: No se pudo determinar el nombre del paquete de la aplicación. La aplicación se cerrará.
replacePrompt: Ya existe una aplicación en {destination}. ¿Quieres reemplazarla y reiniciarla desde Applications?
movePrompt: Para iniciar el servicio en segundo plano de forma segura, la aplicación debe moverse a {destination}. ¿Quieres moverla y reiniciarla ahora?
createApplicationsDirError: 'No se pudo crear la carpeta Applications: {error}'
moveFailed: No se pudo mover la aplicación. Muévela manualmente en Finder e inténtalo de nuevo.
replaceFailed: 'No se pudo reemplazar de forma segura la aplicación existente: {error}'
relaunchFailed: La aplicación se copió, pero no se pudo reiniciar automáticamente. Ábrela manualmente desde Applications.
cancel: Cancelar
move: Mover
ok: Aceptar
tray:
dashboard: Panel
ruleMode: Modo de reglas
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
translocated: macOS این برنامه را از یک مسیر موقت App Translocation اجرا می‌کند. آن را در Finder به‌صورت دستی به /Applications یا ~/Applications منتقل کنید و دوباره باز کنید.
rejected: |-
این برنامه باید از پوشهٔ Applications اجرا شود.
{reason}
notInBundle: فایل اجرایی داخل بستهٔ برنامه نیست.
canonicalizeBundleError: 'مسیر بستهٔ برنامه قابل تشخیص نیست: {error}'
bundleNameError: نام بستهٔ برنامه مشخص نشد. برنامه بسته خواهد شد.
replacePrompt: در {destination} از قبل برنامه‌ای وجود دارد. جایگزین و از Applications دوباره اجرا شود؟
movePrompt: برای اجرای امن سرویس پس‌زمینه، برنامه باید به {destination} منتقل شود. اکنون منتقل و دوباره اجرا شود؟
createApplicationsDirError: 'پوشهٔ Applications ایجاد نشد: {error}'
moveFailed: انتقال برنامه ناموفق بود. آن را در Finder به‌صورت دستی منتقل کنید و دوباره تلاش کنید.
replaceFailed: 'برنامهٔ موجود به‌صورت امن جایگزین نشد: {error}'
relaunchFailed: برنامه کپی شد، اما اجرای دوبارهٔ خودکار ناموفق بود. آن را به‌صورت دستی از Applications باز کنید.
cancel: لغو
move: انتقال
ok: تأیید
tray:
dashboard: داشبورد
ruleMode: حالت قوانین
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
translocated: macOS menjalankan aplikasi ini dari jalur App Translocation sementara. Pindahkan secara manual di Finder ke /Applications atau ~/Applications, lalu buka kembali.
rejected: |-
Aplikasi ini harus dijalankan dari direktori Applications.
{reason}
notInBundle: Berkas yang dapat dijalankan tidak berada di dalam bundel aplikasi.
canonicalizeBundleError: 'Jalur bundel aplikasi tidak dapat diuraikan: {error}'
bundleNameError: Nama bundel aplikasi tidak dapat ditentukan. Aplikasi akan ditutup.
replacePrompt: Sudah ada aplikasi di {destination}. Ganti dan jalankan ulang dari Applications?
movePrompt: Agar layanan latar belakang dapat dimulai dengan aman, aplikasi harus dipindahkan ke {destination}. Pindahkan dan jalankan ulang sekarang?
createApplicationsDirError: 'Direktori Applications tidak dapat dibuat: {error}'
moveFailed: Aplikasi gagal dipindahkan. Pindahkan secara manual di Finder lalu coba lagi.
replaceFailed: 'Aplikasi yang ada tidak dapat diganti dengan aman: {error}'
relaunchFailed: Aplikasi telah disalin, tetapi tidak dapat dijalankan ulang secara otomatis. Buka secara manual dari Applications.
cancel: Batal
move: Pindahkan
ok: OK
tray:
dashboard: Dasbor
ruleMode: Mode Aturan
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
translocated: macOS が一時的な App Translocation パスからこのアプリケーションを実行しています。Finder で /Applications または ~/Applications に手動で移動してから、もう一度開いてください。
rejected: |-
このアプリケーションは Applications ディレクトリから起動する必要があります。
{reason}
notInBundle: 実行ファイルがアプリケーションバンドル内にありません。
canonicalizeBundleError: アプリケーションバンドルのパスを解決できませんでした:{error}
bundleNameError: アプリケーションバンドル名を確認できないため、アプリケーションを終了します。
replacePrompt: '{destination} には既にアプリケーションがあります。置き換えて Applications から再起動しますか?'
movePrompt: バックグラウンドサービスを安全に起動するには、アプリケーションを {destination} に移動する必要があります。今すぐ移動して再起動しますか?
createApplicationsDirError: Applications ディレクトリを作成できませんでした:{error}
moveFailed: アプリケーションを移動できませんでした。Finder で手動で移動してから、もう一度お試しください。
replaceFailed: 既存のアプリケーションを安全に置き換えられませんでした:{error}
relaunchFailed: アプリケーションはコピーされましたが、自動的に再起動できませんでした。Applications から手動で開いてください。
cancel: キャンセル
move: 移動
ok: OK
tray:
dashboard: ダッシュボード
ruleMode: ルールモード
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
translocated: macOS가 임시 App Translocation 경로에서 이 애플리케이션을 실행하고 있습니다. Finder에서 /Applications 또는 ~/Applications로 직접 이동한 후 다시 여십시오.
rejected: |-
이 애플리케이션은 Applications 디렉터리에서 실행해야 합니다.
{reason}
notInBundle: 실행 파일이 애플리케이션 번들 안에 없습니다.
canonicalizeBundleError: '애플리케이션 번들 경로를 확인할 수 없습니다: {error}'
bundleNameError: 애플리케이션 번들 이름을 확인할 수 없어 애플리케이션을 종료합니다.
replacePrompt: '{destination}에 애플리케이션이 이미 있습니다. 교체하고 Applications에서 다시 실행하시겠습니까?'
movePrompt: 백그라운드 서비스를 안전하게 시작하려면 애플리케이션을 {destination}(으)로 이동해야 합니다. 지금 이동하고 다시 실행하시겠습니까?
createApplicationsDirError: 'Applications 디렉터리를 만들 수 없습니다: {error}'
moveFailed: 애플리케이션을 이동하지 못했습니다. Finder에서 직접 이동한 후 다시 시도하십시오.
replaceFailed: '기존 애플리케이션을 안전하게 교체할 수 없습니다: {error}'
relaunchFailed: 애플리케이션을 복사했지만 자동으로 다시 실행하지 못했습니다. Applications에서 직접 여십시오.
cancel: 취소
move: 이동
ok: 확인
tray:
dashboard: 대시보드
ruleMode: 규칙 모드
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
translocated: macOS запускает это приложение из временного пути App Translocation. Переместите его вручную в Finder в /Applications или ~/Applications, затем откройте снова.
rejected: |-
Это приложение необходимо запускать из каталога Applications.
{reason}
notInBundle: Исполняемый файл не находится внутри пакета приложения.
canonicalizeBundleError: 'Не удалось определить путь к пакету приложения: {error}'
bundleNameError: Не удалось определить имя пакета приложения. Приложение будет закрыто.
replacePrompt: В {destination} уже есть приложение. Заменить его и перезапустить из Applications?
movePrompt: Для безопасного запуска фоновой службы приложение необходимо переместить в {destination}. Переместить и перезапустить сейчас?
createApplicationsDirError: 'Не удалось создать каталог Applications: {error}'
moveFailed: Не удалось переместить приложение. Переместите его вручную в Finder и повторите попытку.
replaceFailed: 'Не удалось безопасно заменить существующее приложение: {error}'
relaunchFailed: Приложение скопировано, но его не удалось перезапустить автоматически. Откройте его вручную из Applications.
cancel: Отмена
move: Переместить
ok: ОК
tray:
dashboard: Панель
ruleMode: Режим правил
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
translocated: macOS bu uygulamayı geçici bir App Translocation yolundan çalıştırıyor. Finder'da uygulamayı elle /Applications veya ~/Applications dizinine taşıyın ve yeniden açın.
rejected: |-
Bu uygulama bir Applications dizininden başlatılmalıdır.
{reason}
notInBundle: Yürütülebilir dosya bir uygulama paketinin içinde değil.
canonicalizeBundleError: 'Uygulama paketinin yolu çözümlenemedi: {error}'
bundleNameError: Uygulama paketinin adı belirlenemedi. Uygulama kapatılacak.
replacePrompt: '{destination} konumunda zaten bir uygulama var. Değiştirilsin ve Applications üzerinden yeniden başlatılsın mı?'
movePrompt: Arka plan hizmetini güvenli şekilde başlatmak için uygulamanın {destination} konumuna taşınması gerekir. Şimdi taşınıp yeniden başlatılsın mı?
createApplicationsDirError: 'Applications dizini oluşturulamadı: {error}'
moveFailed: Uygulama taşınamadı. Finder'da elle taşıyıp tekrar deneyin.
replaceFailed: 'Mevcut uygulama güvenli şekilde değiştirilemedi: {error}'
relaunchFailed: Uygulama kopyalandı ancak otomatik olarak yeniden başlatılamadı. Applications üzerinden elle açın.
cancel: İptal
move: Taşı
ok: Tamam
tray:
dashboard: Gösterge Paneli
ruleMode: Kural Modu
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
translocated: macOS бу кушымтаны вакытлы App Translocation юлыннан эшләтә. Finder аша аны /Applications яки ~/Applications каталогына кулдан күчерегез һәм яңадан ачыгыз.
rejected: |-
Бу кушымта Applications каталогыннан эшләтелергә тиеш.
{reason}
notInBundle: Башкарылучы файл кушымта пакетында түгел.
canonicalizeBundleError: 'Кушымта пакетының юлын билгеләп булмады: {error}'
bundleNameError: Кушымта пакетының исемен билгеләп булмады. Кушымта ябылачак.
replacePrompt: '{destination} урынында кушымта бар инде. Аны алыштырып, Applications аша яңадан эшләтергәме?'
movePrompt: Фон хезмәтен имин эшләтү өчен кушымтаны {destination} урынына күчерергә кирәк. Хәзер күчереп, яңадан эшләтергәме?
createApplicationsDirError: 'Applications каталогын төзеп булмады: {error}'
moveFailed: Кушымтаны күчереп булмады. Аны Finder аша кулдан күчереп, кабатлап карагыз.
replaceFailed: 'Булган кушымтаны имин алыштырып булмады: {error}'
relaunchFailed: Кушымта күчерелде, ләкин автоматик рәвештә яңадан эшләтеп булмады. Аны Applications аша кулдан ачыгыз.
cancel: Баш тарту
move: Күчерү
ok: Ярый
tray:
dashboard: Идарә панеле
ruleMode: Кагыйдә режимы
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
translocatedCorePath: 检测到 Clash Verge 从临时路径(App Translocation)运行,无法定位内核。请将 Clash Verge 移动到「应用程序」文件夹后重新打开。
launchGuard:
currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
translocated: macOS 正在从 App Translocation 临时路径运行此应用。请在 Finder 中将应用手动拖到 /Applications 或 ~/Applications 后重新打开。
rejected: |-
此应用必须从 Applications 目录启动。
{reason}
notInBundle: 可执行文件不在应用包内。
canonicalizeBundleError: 无法解析应用包路径:{error}
bundleNameError: 无法确定应用包名称,应用将退出。
replacePrompt: 目标位置已存在应用:{destination}。是否替换并从 Applications 重新启动?
movePrompt: 为了安全启动后台服务,需要将应用移动到 {destination}。是否现在移动并重新启动?
createApplicationsDirError: 无法创建 Applications 目录:{error}
moveFailed: 移动应用失败。请在 Finder 中手动移动后重试。
replaceFailed: 无法安全替换现有应用:{error}
relaunchFailed: 应用已复制,但自动重新启动失败。请从 Applications 手动打开。
cancel: 取消
move: 移动
ok: 好
tray:
dashboard: 仪表板
ruleMode: 规则模式
+20 -1
View File
@@ -34,7 +34,26 @@ notifications:
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
translocatedCorePath: Clash Verge is running from a temporary (App Translocation) path and can't locate the core. Please move Clash Verge into the Applications folder and reopen it.
launchGuard:
currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
translocated: macOS 正從 App Translocation 暫存路徑執行此應用程式。請在 Finder 中將應用程式手動移至 /Applications 或 ~/Applications,然後重新開啟。
rejected: |-
此應用程式必須從 Applications 目錄啟動。
{reason}
notInBundle: 可執行檔不在應用程式套件內。
canonicalizeBundleError: 無法解析應用程式套件路徑:{error}
bundleNameError: 無法判斷應用程式套件名稱,應用程式將結束。
replacePrompt: 目標位置已有應用程式:{destination}。是否取代並從 Applications 重新啟動?
movePrompt: 為了安全啟動背景服務,需要將應用程式移至 {destination}。是否立即移動並重新啟動?
createApplicationsDirError: 無法建立 Applications 目錄:{error}
moveFailed: 移動應用程式失敗。請在 Finder 中手動移動後再試一次。
replaceFailed: 無法安全取代現有應用程式:{error}
relaunchFailed: 應用程式已複製,但無法自動重新啟動。請從 Applications 手動開啟。
cancel: 取消
move: 移動
ok: 好
tray:
dashboard: 儀表板
ruleMode: 規則模式
+1 -1
View File
@@ -39,7 +39,7 @@ fn resolve_supported_language(language: &str) -> Option<Cow<'static, str>> {
}
#[inline]
fn current_language(language: Option<&str>) -> Cow<'static, str> {
pub fn current_language(language: Option<&str>) -> Cow<'static, str> {
language
.filter(|lang| !lang.is_empty())
.and_then(resolve_supported_language)
+22 -10
View File
@@ -97,23 +97,35 @@ pub fn write_sidecar_log(
let _ = writer.write(now, &record);
}
pub struct NoModuleFilter<'a>(pub Vec<&'a str>);
pub struct ModuleFilter<'a> {
block: Vec<&'a str>,
exclude: Option<Vec<&'a str>>,
}
impl<'a> ModuleFilter<'a> {
pub fn new(block: Vec<&'a str>, exclude: Option<Vec<&'a str>>) -> Self {
Self { block, exclude }
}
impl<'a> NoModuleFilter<'a> {
#[inline]
pub fn filter(&self, record: &Record) -> bool {
if let Some(module) = record.module_path() {
for blocked in self.0.iter() {
if module.len() >= blocked.len() && module.as_bytes()[..blocked.len()] == blocked.as_bytes()[..] {
return false;
}
}
let Some(module) = record.module_path() else {
return true;
};
// 优先检查排除(白名单)
if let Some(excludes) = &self.exclude
&& excludes.iter().any(|e| module.starts_with(e))
{
return true;
}
true
// 再检查阻止(黑名单)
!self.block.iter().any(|b| module.starts_with(b))
}
}
impl<'a> LogLineFilter for NoModuleFilter<'a> {
impl<'a> LogLineFilter for ModuleFilter<'a> {
#[inline]
fn write(
&self,
+1 -1
View File
@@ -7,7 +7,7 @@ rust-version = "1.91"
[dependencies]
clash-verge-logging = { workspace = true }
log = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["signal"] }
[lints]
workspace = true
+63 -2
View File
@@ -1,4 +1,7 @@
use std::sync::OnceLock;
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
use clash_verge_logging::{Type, logging};
@@ -8,11 +11,42 @@ mod unix;
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
impl ShutdownLatch {
const fn new() -> Self {
Self {
is_cleaning_up: AtomicBool::new(false),
}
}
fn try_begin(&self) -> bool {
self.is_cleaning_up
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
}
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -33,3 +67,30 @@ where
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::{ShutdownLatch, ShutdownOutcome};
#[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+4 -10
View File
@@ -1,16 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::RUNTIME;
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -53,7 +49,7 @@ where
}
}
if IS_CLEANING_UP.load(Ordering::SeqCst) {
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
@@ -62,11 +58,9 @@ where
);
continue;
}
IS_CLEANING_UP.store(true, Ordering::SeqCst);
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
+4 -10
View File
@@ -1,16 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::RUNTIME;
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -63,7 +59,7 @@ where
}
}
if IS_CLEANING_UP.load(Ordering::SeqCst) {
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
@@ -72,11 +68,9 @@ where
);
continue;
}
IS_CLEANING_UP.store(true, Ordering::SeqCst);
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
@@ -11,10 +11,10 @@ parking_lot = { workspace = true }
serde = { workspace = true, features = ["derive"] }
# sysinfo 0.38.2 conflicts with dark-light
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
sysinfo = { version = "0.39.5", features = ["network", "system"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.186"
libc = "0.2.189"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
+85
View File
@@ -1,3 +1,88 @@
## v2.5.2
### 🐞 修复问题
- 修复 macOS 托盘速率显示样式异常
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
- 修复前端连接页面导致的内存泄漏
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
- 修复首页代理卡片可能错误显示通信异常的问题
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
- 修复 DNS 覆写的高级模式无法正常编辑
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
- 修复 Linux 应用内更新问题
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
- 修复全局热键开关设置可能被意外覆盖的问题
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
- 修复 macOS 托盘图标可能闪烁的问题
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
- 修复首页上传和下载总流量统计不准确的问题
- 修复首页当前代理状态偶尔不同步的问题
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
- 修复切换代理模式失败时未正确显示错误的问题
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
- 修复全局快捷键隐藏主窗口行为异常的问题
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
- 修复日志刷新时页面可能被强制滚动到底部的问题
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
- 修复代理组筛选后定位及滚动位置恢复异常的问题
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
- 修复内核启动慢导致首页上下跳动的问题
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- **Mihomo(Meta) 内核升级至 v1.19.29**
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
- 全局扩展脚本增加恢复默认按钮
- DNS 添加 fake-ip-range6 可配置项
- Linux 无边框窗口支持拖拽调整大小
- 代理组新增筛选、排序、延迟测试和快速定位工具
- 首页当前节点过滤已隐藏的代理组
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 更健壮的服务生命周期管理
- 更健壮的 Mihomo API 通信机制
- 关闭 autofill 弹出窗口
- 改进切换订阅后激活选中节点的逻辑
- 实现代理组粘性滚动列表
- 完善了配置覆写的相关逻辑
- 支持 Provider 节点延迟检测
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
- 重构改进订阅配置切换逻辑
- 优化大量订阅卡片时的拖拽排序性能
</details>
<details>
<summary><strong> 👙 界面样式 </strong></summary>
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
- 优化代理组工具栏和筛选框布局
</details>
## v2.5.1
### 🐞 修复问题
+2
View File
@@ -43,7 +43,9 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
],
},
},
+25 -17
View File
@@ -1,12 +1,14 @@
{
"name": "clash-verge",
"version": "2.5.2",
"version": "2.5.3",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
"dev": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
"dev:diff": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
"dev:trace": "cross-env RUST_BACKTRACE=full RUSTFLAGS=\"--cfg tokio_unstable\" tauri dev -f verge-dev tokio-trace",
"dev": "node scripts/dev.mjs dev",
"dev:service": "node scripts/dev.mjs service",
"dev:sidecar": "node scripts/dev.mjs sidecar",
"dev:diff": "node scripts/dev.mjs dev",
"dev:trace": "node scripts/dev.mjs trace",
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
@@ -24,6 +26,7 @@
"release:autobuild": "pnpm release-version autobuild",
"release:deploytest": "pnpm release-version deploytest",
"publish-version": "node scripts/publish-version.mjs",
"eslint": "pnpm lint",
"lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src",
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
"format": "biome format --write .",
@@ -32,6 +35,10 @@
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:dev-control": "node --test scripts/dev-control.test.mjs scripts/service-release.test.mjs",
"knip:check": "knip",
"knip:report": "knip --no-exit-code"
},
"dependencies": {
@@ -50,7 +57,6 @@
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.5.9",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-updater": "2.10.1",
"ahooks": "^3.9.7",
@@ -59,23 +65,23 @@
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^4.1.1",
"js-yaml": "^5.0.0",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.55.1",
"monaco-yaml": "^5.5.0",
"nanoid": "^5.1.11",
"nanoid": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.2",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.8",
"react-i18next": "17.0.11",
"react-markdown": "10.1.0",
"react-router": "^8.0.0",
"rehype-raw": "^7.0.0",
"swr": "^2.4.2",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#refactor-use-reqwest-ipc",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
@@ -85,7 +91,7 @@
"@eslint-react/eslint-plugin": "^5.8.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.11.4",
"@types/js-yaml": "^4.0.9",
"@testing-library/react": "^16.3.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4",
"@types/react": "19.2.17",
@@ -93,14 +99,14 @@
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"adm-zip": "^0.5.17",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^9.6.0",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -112,7 +118,8 @@
"https-proxy-agent": "^9.0.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.26.0",
"jsdom": "^30.0.0",
"knip": "6.29.0",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
@@ -120,14 +127,15 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0"
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.10"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
"eslint --fix --max-warnings=0",
"biome format --write"
],
"*.{css,scss,json,yaml,yml}": [
"*.{css,scss,json}": [
"biome format --write"
]
},
+1636 -1187
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,3 +6,4 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- jsdom@30.0.0
+280
View File
@@ -0,0 +1,280 @@
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import { request } from 'node:http'
import { connect } from 'node:net'
const DEFAULT_TIMEOUT_MS = 10_000
const DEFAULT_RETRY_DELAY_MS = 100
const TRANSIENT_REQUEST_ERRORS = new Set([
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
])
const SAFE_REQUEST_ERROR_CODES = new Set([
...TRANSIENT_REQUEST_ERRORS,
'EACCES',
'EINVAL',
'ENOTFOUND',
])
function validateInstanceRecord(record) {
if (
!record ||
!Number.isInteger(record.port) ||
record.port < 1 ||
record.port > 65_535
) {
throw new Error('invalid instance record port')
}
if (
typeof record.token !== 'string' ||
!/^[0-9a-f]{64}$/.test(record.token)
) {
throw new Error('invalid instance record token')
}
return { port: record.port, token: record.token }
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function safeRequestError(error) {
const code = SAFE_REQUEST_ERROR_CODES.has(error?.code)
? error.code
: 'UNKNOWN'
const cause = new Error(code)
cause.code = code
return new Error('dev quit request failed', { cause })
}
export async function readPrivateInstanceRecord(path, expectedUid) {
let handle
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
const stat = await handle.stat()
if (!stat.isFile()) {
throw new Error('instance record must be an ordinary file')
}
if (stat.uid !== expectedUid) {
throw new Error('instance record has the wrong owner')
}
if ((stat.mode & 0o777) !== 0o600) {
throw new Error('instance record permissions must be 0600')
}
const contents = await handle.readFile({ encoding: 'utf8' })
let record
try {
record = JSON.parse(contents)
} catch {
throw new Error('instance record is not valid JSON')
}
return validateInstanceRecord(record)
} finally {
await handle?.close()
}
}
function postDevQuit(record, timeoutMs, requestImpl) {
return new Promise((resolve, reject) => {
let settled = false
const settle = (callback, value) => {
if (settled) return
settled = true
callback(value)
}
const outgoing = requestImpl(
{
host: '127.0.0.1',
port: record.port,
path: '/commands/dev/quit',
method: 'POST',
agent: false,
headers: {
'X-Instance-Token': record.token,
},
},
(response) => {
response.on('aborted', () => {})
response.on('error', () => {})
response.on('close', () => {})
settle(resolve, response.statusCode)
response.destroy()
},
)
outgoing.once('error', (error) => settle(reject, error))
outgoing.setTimeout(timeoutMs, () => {
const error = new Error('dev quit request timed out')
error.code = 'ETIMEDOUT'
outgoing.destroy(error)
})
outgoing.end()
})
}
export async function requestDevQuit(
recordOrReader,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
requestImpl = request,
now = Date.now,
sleep = delay,
} = {},
) {
const deadline = now() + timeoutMs
const readRecord =
typeof recordOrReader === 'function'
? recordOrReader
: async () => recordOrReader
while (now() < deadline) {
let record
try {
record = validateInstanceRecord(await readRecord())
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
const remainingMs = Math.max(1, deadline - now())
let status
try {
status = await postDevQuit(record, remainingMs, requestImpl)
} catch (error) {
if (!TRANSIENT_REQUEST_ERRORS.has(error?.code)) {
throw safeRequestError(error)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
if (status === 202) {
return record
}
if (status !== 503) {
throw new Error(
`dev quit request returned HTTP ${status ?? 'without a status'}`,
)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev quit request to be accepted')
}
function portAcceptsConnections(port, timeoutMs) {
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port })
let settled = false
const finish = (isOpen) => {
if (settled) return
settled = true
socket.destroy()
resolve(isOpen)
}
socket.once('connect', () => finish(true))
socket.once('error', () => finish(false))
socket.setTimeout(Math.max(1, Math.min(1_000, timeoutMs)), () =>
finish(false),
)
})
}
export async function waitForPortClosed(
port,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
probePort = portAcceptsConnections,
now = Date.now,
sleep = delay,
} = {},
) {
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error('invalid loopback port')
}
const deadline = now() + timeoutMs
while (now() < deadline) {
const remainingMs = deadline - now()
if (!(await probePort(port, remainingMs))) {
return
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev endpoint to close')
}
export function createMacosShutdownController({
requestQuit,
waitForPortClosed: waitForClosed,
waitForChildExit,
terminateGroup,
finalizeGroup = terminateGroup,
exit,
report = (message) => console.error(message),
timeoutMs = DEFAULT_TIMEOUT_MS,
forceTimeoutMs = 2_000,
now = Date.now,
}) {
let signalCount = 0
let forced = false
let forcedPromise
let groupFinalizationPromise
const remainingBudget = (deadline) => {
const remaining = deadline - now()
if (remaining <= 0) {
throw new Error('timed out waiting for graceful dev shutdown')
}
return remaining
}
const finalizeOnce = (operation, budget) => {
groupFinalizationPromise ??= Promise.resolve().then(() => operation(budget))
return groupFinalizationPromise
}
const gracefulShutdown = async () => {
const deadline = now() + timeoutMs
try {
const record = await requestQuit(remainingBudget(deadline))
if (forced) return false
await waitForClosed(record.port, remainingBudget(deadline))
if (forced) return false
await waitForChildExit(remainingBudget(deadline))
if (forced) return false
await finalizeOnce(finalizeGroup, remainingBudget(deadline))
if (forced) return false
exit(130)
return true
} catch (error) {
report(
`Graceful dev shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
)
return false
}
}
return {
handleSigint() {
signalCount += 1
if (signalCount === 1) {
return gracefulShutdown()
}
if (!forcedPromise) {
forced = true
forcedPromise = finalizeOnce(terminateGroup, forceTimeoutMs).then(
() => {
exit(130)
return true
},
)
}
return forcedPromise
},
}
}
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise()
return
}
const error = new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`,
)
error.exitCode = code
reject(error)
})
})
}
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
export function windowsElevationInvocation(
installer,
environment = process.env,
) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
+566
View File
@@ -0,0 +1,566 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
createMacosShutdownController,
readPrivateInstanceRecord,
requestDevQuit,
waitForPortClosed,
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
const FORCE_GRACE_MS = 2_000
const SHUTDOWN_TIMEOUT_MS = 10_000
const FORCE_POLL_MS = 50
const ANCHOR_EXIT_RESERVE_MS = 100
export function buildTauriInvocation(
mode,
environment = process.env,
platform = process.platform,
) {
const env = { ...environment, RUST_BACKTRACE: 'full' }
const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev'
const args = ['exec', 'tauri', 'dev', '-f', features]
if (mode === 'trace') {
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
return {
command: 'pnpm',
args,
env,
detached: platform === 'darwin',
}
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function childLifecycle(child) {
const spawned = new Promise((resolve, reject) => {
child.once('spawn', resolve)
child.once('error', reject)
})
spawned.catch(() => {})
const result = new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve({ code, signal }))
})
result.catch(() => {})
return { result, spawned }
}
function waitForPromise(promise, timeoutMs, message) {
if (timeoutMs <= 0) return Promise.reject(new Error(message))
let timeout
return Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timeout))
}
function signalDetachedGroup(childPid, signal, signalGroup) {
if (!Number.isInteger(childPid) || childPid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
try {
signalGroup(-childPid, signal)
return true
} catch (error) {
if (error?.code === 'ESRCH') return false
throw error
}
}
export async function terminateDetachedGroup(
childPid,
{
signalGroup = process.kill.bind(process),
sleep = wait,
now = Date.now,
graceMs = FORCE_GRACE_MS,
pollMs = FORCE_POLL_MS,
} = {},
) {
if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return
const deadline = now() + graceMs
while (now() < deadline) {
await sleep(Math.min(pollMs, deadline - now()))
if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) {
return
}
}
signalDetachedGroup(childPid, 'SIGKILL', signalGroup)
}
export function mirrorChildExit(
{ code, signal },
{
exit = (exitCode) => process.exit(exitCode),
signalSelf = (childSignal) => {
process.removeAllListeners(childSignal)
process.kill(process.pid, childSignal)
},
} = {},
) {
if (code !== null) {
exit(code)
return
}
if (signal) {
signalSelf(signal)
return
}
exit(1)
}
function defaultInstallSigint(handler) {
process.on('SIGINT', handler)
return () => process.off('SIGINT', handler)
}
function defaultInstallSigterm(handler) {
process.on('SIGTERM', handler)
return () => process.off('SIGTERM', handler)
}
function defaultInstallMessage(handler) {
process.on('message', handler)
return () => process.off('message', handler)
}
function defaultInstallDisconnect(handler) {
process.on('disconnect', handler)
return () => process.off('disconnect', handler)
}
function sendToParent(message) {
if (process.connected && process.send) process.send(message, () => {})
}
export async function runDevAnchor(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs --anchor <dev|trace|sidecar>')
}
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const send = dependencies.send ?? sendToParent
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const ownPid = dependencies.ownPid ?? process.pid
const terminateGroup =
dependencies.terminateGroup ??
((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS }))
let released = false
let disconnectCleanup
const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm
installSigterm(() => {})
const installMessage = dependencies.installMessage ?? defaultInstallMessage
installMessage((message) => {
if (message?.type === 'release' && !disconnectCleanup) {
released = true
exit(0)
}
})
const installDisconnect =
dependencies.installDisconnect ?? defaultInstallDisconnect
installDisconnect(() => {
if (released || disconnectCleanup) return
disconnectCleanup = Promise.resolve()
.then(() => terminateGroup(ownPid))
.then(() => exit(1))
.catch((error) => {
report(`Dev anchor cleanup failed: ${error.message}`)
exit(1)
})
})
const invocation = buildTauriInvocation(mode, environment, 'darwin')
let inner
try {
inner = spawnChild(invocation.command, invocation.args, {
env: invocation.env,
detached: false,
stdio: 'inherit',
})
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
if (!inner || typeof inner.once !== 'function') {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
const innerLifecycle = childLifecycle(inner)
try {
await innerLifecycle.spawned
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
send({ type: 'ready' })
innerLifecycle.result.then(
({ code, signal }) => send({ type: 'inner-result', code, signal }),
() => send({ type: 'inner-error', code: 'INNER_FAILED' }),
)
}
function createAnchorChannel(anchor, anchorResult) {
let innerSettled = false
let readySettled = false
let resolveResult
let rejectResult
let resolveReady
let rejectReady
const innerResult = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
const ready = new Promise((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const settleInner = (callback, value) => {
if (innerSettled) return
innerSettled = true
callback(value)
}
const settleReady = (callback, value) => {
if (readySettled) return
readySettled = true
callback(value)
}
anchor.on('message', (message) => {
if (message?.type === 'ready') {
settleReady(resolveReady)
} else if (message?.type === 'inner-result') {
settleInner(resolveResult, {
code: message.code,
signal: message.signal,
})
} else if (message?.type === 'inner-error') {
const error = new Error(
`inner dev command failed (${message.code ?? 'UNKNOWN'})`,
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
}
})
anchorResult.then(
() => {
const error = new Error(
'dev group anchor exited before the inner command completed',
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
() => {
const error = new Error('dev group anchor failed')
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
)
ready.catch(() => {})
innerResult.catch(() => {})
return { innerResult, ready }
}
function sendRelease(anchor) {
return new Promise((resolve, reject) => {
if (typeof anchor.send !== 'function') {
reject(new Error('dev group anchor IPC is unavailable'))
return
}
anchor.send({ type: 'release' }, (error) => {
if (error) reject(new Error('dev group anchor release failed'))
else resolve()
})
})
}
function unsafeAnchorError() {
return new Error('dev group anchor identity is unsafe')
}
export async function runDevCommand(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs <dev|trace|sidecar>')
}
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit
let recordPath
let pendingSigints = 0
let dispatchSigint = () => {
pendingSigints += 1
}
let removeSigint = () => {}
if (platform === 'darwin') {
if (!environment.HOME) {
throw new Error('HOME is required to locate the dev instance record')
}
recordPath = join(
environment.HOME,
'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json',
)
const installSigint = dependencies.installSigint ?? defaultInstallSigint
removeSigint = installSigint(() => dispatchSigint()) ?? (() => {})
}
const tauriInvocation = buildTauriInvocation(mode, environment, platform)
const anchorInvocation = {
command: dependencies.execPath ?? process.execPath,
args: [fileURLToPath(import.meta.url), '--anchor', mode],
env: tauriInvocation.env,
detached: true,
}
const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation
let child
try {
child = spawnChild(invocation.command, invocation.args, {
shell: platform === 'win32',
env: invocation.env,
detached: invocation.detached,
stdio:
platform === 'darwin'
? ['inherit', 'inherit', 'inherit', 'ipc']
: 'inherit',
})
} catch (error) {
removeSigint()
throw error
}
if (!child || typeof child.once !== 'function') {
removeSigint()
throw new Error('dev command failed to return a child process')
}
const outerLifecycle = childLifecycle(child)
if (platform !== 'darwin') {
mirrorResult(await outerLifecycle.result)
return
}
let groupState = 'safe'
let finalKillSent = false
const anchorChannel = createAnchorChannel(child, outerLifecycle.result)
outerLifecycle.result.then(
() => {
if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe'
},
() => {
groupState = 'unsafe'
},
)
try {
await outerLifecycle.spawned
if (!Number.isInteger(child.pid) || child.pid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
await waitForPromise(
anchorChannel.ready,
SHUTDOWN_TIMEOUT_MS,
'timed out waiting for the dev group anchor to initialize',
)
} catch (error) {
removeSigint()
throw error
}
const anchorPid = child.pid
const innerResult = anchorChannel.innerResult
let intercepted = false
let observedShutdownOperation
let anchorFinalization
const signalGroup = dependencies.signalGroup ?? process.kill.bind(process)
const guardedSignalGroup = (pid, signal) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
if (signal === 'SIGKILL') finalKillSent = true
try {
signalGroup(pid, signal)
} catch (error) {
if (error?.code === 'ESRCH') {
groupState = 'unsafe'
throw unsafeAnchorError()
}
throw error
}
}
const assertGroupAbsent = () => {
try {
signalGroup(-anchorPid, 0)
} catch (error) {
if (error?.code === 'ESRCH') return
throw new Error('unable to verify that the dev process group exited', {
cause: error,
})
}
throw new Error('dev process group still exists after anchor exit')
}
const forceAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'terminating'
if (dependencies.terminateGroup) {
await dependencies.terminateGroup(anchorPid, budget)
return
}
const deadline = Date.now() + budget
await terminateDetachedGroup(anchorPid, {
graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS),
signalGroup: guardedSignalGroup,
})
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the dev group anchor to exit',
)
}
const releaseAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'released'
const deadline = Date.now() + budget
await waitForPromise(
sendRelease(child),
Math.max(1, deadline - Date.now()),
'timed out releasing the dev group anchor',
)
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the released dev group anchor',
)
assertGroupAbsent()
}
const finalizeAnchorOnce = (kind, budget) => {
anchorFinalization ??=
kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget)
return anchorFinalization
}
const getUid = dependencies.getUid ?? (() => process.getuid())
const requestQuit =
dependencies.requestQuit ??
((budget) =>
requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), {
timeoutMs: budget,
}))
const controller = createMacosShutdownController({
requestQuit,
waitForPortClosed: (port, budget) =>
(dependencies.waitForPortClosed ?? waitForPortClosed)(port, {
timeoutMs: budget,
}),
waitForChildExit: (budget) =>
waitForPromise(
innerResult,
budget,
'timed out waiting for the inner dev command to exit',
),
finalizeGroup: (budget) => finalizeAnchorOnce('release', budget),
terminateGroup: (budget) => finalizeAnchorOnce('force', budget),
exit,
report,
timeoutMs: SHUTDOWN_TIMEOUT_MS,
})
dispatchSigint = () => {
intercepted = true
const operation = controller.handleSigint()
if (operation !== observedShutdownOperation) {
observedShutdownOperation = operation
void operation.catch((error) => {
report(`Forced dev shutdown failed: ${error.message}`)
exit(1)
})
}
}
const queuedSigints = pendingSigints
pendingSigints = 0
for (let index = 0; index < queuedSigints; index += 1) {
dispatchSigint()
}
innerResult.then(
async (result) => {
if (intercepted) return
try {
await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS)
if (!intercepted) mirrorResult(result)
} catch (error) {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
}
},
(error) => {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
},
)
}
export async function prepareDevRuntime(mode, dependencies = {}) {
const ensureService = dependencies.ensureService ?? ensureDevelopmentService
const prepareService =
dependencies.prepareService ?? prepareDevelopmentService
let serviceDirectory
if (mode === 'service') {
serviceDirectory = await ensureService()
} else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') {
serviceDirectory = await prepareService()
}
return {
mode: mode === 'service' ? 'dev' : mode,
serviceDirectory,
}
}
async function run() {
if (process.argv[2] === '--anchor') {
await runDevAnchor(process.argv[3])
} else {
const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2])
if (serviceDirectory) {
process.env[developmentServiceDirectoryEnvironment] = serviceDirectory
}
await runDevCommand(mode)
}
}
if (
process.argv[1] &&
pathToFileURL(process.argv[1]).href === import.meta.url
) {
run().catch((error) => {
console.error(`Unable to start dev command: ${error.message}`)
process.exit(1)
})
}
+10 -63
View File
@@ -10,6 +10,7 @@ import { glob } from 'glob'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { extract } from 'tar'
import { resolveServiceRelease } from './service-release.mjs'
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
/**
@@ -577,12 +578,6 @@ const resolveServicePermission = async () => {
// =======================
// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback)
// =======================
const SERVICE_LATEST_URL =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/latest'
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
let SERVICE_VERSION
const SERVICE_BINARIES = [
'clash-verge-service',
'clash-verge-service-install',
@@ -598,53 +593,6 @@ function serviceFileInfo(name) {
}
}
function parseServiceVersionFromUrl(url) {
const match = url.match(/\/releases\/tag\/([^/?#]+)/)
return match ? decodeURIComponent(match[1]) : null
}
async function getLatestServiceVersion() {
if (!FORCE) {
const cached = await getCachedVersion('SERVICE_VERSION')
if (cached) {
SERVICE_VERSION = cached
return
}
}
const options = {}
const httpProxy =
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy
if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy)
try {
const response = await fetch(SERVICE_LATEST_URL, {
...options,
method: 'GET',
redirect: 'follow',
})
if (!response.ok)
throw new Error(
`Failed to fetch ${SERVICE_LATEST_URL}: ${response.status}`,
)
SERVICE_VERSION = parseServiceVersionFromUrl(response.url)
if (!SERVICE_VERSION)
throw new Error(
`Unable to resolve service release tag from ${response.url}`,
)
log_info(`Latest service version: ${SERVICE_VERSION}`)
await setCachedVersion('SERVICE_VERSION', SERVICE_VERSION)
} catch (err) {
log_error('Error fetching latest service version:', err.message)
process.exit(1)
}
}
async function findExtractedFile(dir, fileName) {
const entries = await fsp.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
@@ -667,16 +615,15 @@ async function resolveServiceBundle() {
}
})
if (!FORCE && files.every(({ targetPath }) => fs.existsSync(targetPath))) {
log_success('"clash-verge-service-ipc" already exists, skipping download')
return
}
await getLatestServiceVersion()
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${SERVICE_VERSION}-${SIDECAR_HOST}.${archiveExt}`
const downloadURL = `${SERVICE_URL_PREFIX}/${SERVICE_VERSION}/${archiveFile}`
const cargoManifest = await fsp.readFile(
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
platform,
)
const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc')
const tempArchive = path.join(tempDir, archiveFile)
+23
View File
@@ -0,0 +1,23 @@
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
export function resolveServiceRelease(cargoManifest, host, platform) {
const dependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const packageVersion = dependency?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
if (!packageVersion) {
throw new Error(
'clash_verge_service_ipc dependency must declare an inline version',
)
}
const version = `v${packageVersion}`
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${version}-${host}.${archiveExt}`
return {
version,
archiveFile,
downloadURL: `${SERVICE_URL_PREFIX}/${version}/${archiveFile}`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { resolveServiceRelease } from './service-release.mjs'
test('service prebuild release follows the Cargo dependency version', async () => {
const cargoManifest = await readFile(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
)
const dependencyVersion = cargoManifest
.split(/\r?\n/)
.find((line) => line.startsWith('clash_verge_service_ipc ='))
?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
assert.ok(dependencyVersion)
const releaseVersion = `v${dependencyVersion}`
assert.deepEqual(
resolveServiceRelease(cargoManifest, 'x86_64-pc-windows-msvc', 'win32'),
{
version: releaseVersion,
archiveFile: `clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
downloadURL: `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${releaseVersion}/clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
},
)
})
test('service prebuild rejects a dependency without an explicit version', () => {
assert.throws(
() =>
resolveServiceRelease(
'clash_verge_service_ipc = { path = "../service" }',
'x86_64-pc-windows-msvc',
'win32',
),
/must declare an inline version/,
)
})
+14 -10
View File
@@ -33,7 +33,7 @@ async function sendTelegramNotification() {
log_info(`Download URL: ${downloadUrl}`)
// 读取发布说明和下载地址
let releaseContent = ''
let releaseContent
try {
releaseContent = readFileSync('release.txt', 'utf-8')
log_info('成功读取 release.txt 文件')
@@ -92,15 +92,19 @@ async function sendTelegramNotification() {
function sanitizeTelegramHTML(content) {
// Telegram supports: b, strong, i, em, u, ins, s, strike, del,
// a, code, pre, blockquote, tg-spoiler, tg-emoji
const allowedTags =
/^\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(\s|>|$)/i
return content.replace(/<\/?[^>]*>/g, (tag) => {
const inner = tag.replace(/^<\/?/, '').replace(/>$/, '')
if (allowedTags.test(inner) || allowedTags.test(tag.slice(1))) {
return tag
}
// Escape unsupported tags so they display as text
return tag.replace(/</g, '&lt;').replace(/>/g, '&gt;')
const allowedTag =
/^<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?:\s[^<>]*)?>$/i
// Match a well-formed tag (name first, no nested angle brackets) OR a lone
// '<'/'>'. Matching lone brackets is what keeps a stray '<' in the changelog
// (e.g. "原始配置 < Merge") from greedily swallowing the next real tag and
// leaving its closing tag orphaned ("Unexpected end tag").
return content.replace(/<\/?[a-z][^<>]*>|[<>]/gi, (token) => {
if (token === '<') return '&lt;'
if (token === '>') return '&gt;'
// Keep supported tags; escape everything else so it displays as text.
return allowedTag.test(token)
? token
: token.replace(/</g, '&lt;').replace(/>/g, '&gt;')
})
}
+27 -26
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.2"
version = "2.5.3"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -17,7 +17,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
verge-dev = ["clash_verge_logger/color"]
verge-dev = ["clash_verge_logger/color", "clash_verge_service_ipc/development-channel"]
dev-sidecar = ["verge-dev"]
tauri-dev = []
tokio-trace = ["console-subscriber"]
clippy = ["tauri/test"]
@@ -55,63 +56,59 @@ serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.3.6"
open = "5.4.0"
dunce = "1.0.5"
nanoid = "0.5"
chrono = "0.4.45"
boa_engine = "0.21.1"
once_cell = { version = "1.21.4", features = ["parking_lot"] }
percent-encoding = "2.3.2"
reqwest = { version = "0.13.4", features = [
"json",
"cookies",
"gzip",
"rustls",
"form",
] }
regex = "1.12.4"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", features = [
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
regex = "1.13.1"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "main", features = [
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-fs = "2.5.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-window-state = "2.4.1"
zip = "8.6.0"
reqwest_dav = "0.3.3"
serde-xml-rs = "0.6.0"
aes-gcm = { version = "0.11.0" }
base64 = "0.22.1"
base64 = "0.23.0"
getrandom = "0.4.3"
futures = "0.3.32"
futures = "0.3.33"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.18"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.5.9"
console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.1.0" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "refactor-use-reqwest-ipc" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.3.3", features = [
async-trait = "0.1.91"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.1", version = "2.6.1", features = [
"client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.4"
rustls = { version = "0.23.41", features = ["ring"] }
webpki-roots = "1.0.8"
rustls = { version = "0.23.42", features = ["ring"] }
webpki-roots = "1.0.9"
rust_iso3166 = "0.2.0"
# Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
windows-sys.workspace = true
bytes = "1.12.0"
tokio-util = { version = "0.7.18", features = ["time"] }
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
@@ -140,7 +137,9 @@ objc2-app-kit = { version = "0.3.2", features = [
deelevate = { workspace = true }
runas = "=1.2.0"
winreg = "0.56.0"
windows-service = "0.8.1"
windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1"
@@ -149,6 +148,8 @@ tauri-plugin-updater = "2.10.1"
[dev-dependencies]
criterion = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
warp = { version = "0.4.3", features = ["test"] }
[lints]
workspace = true
+33 -1
View File
@@ -1,4 +1,34 @@
fn main() {
fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
{
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
// Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
std::fs::write(
&manifest_path,
r#"<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
"#,
)?;
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
}
#[cfg(feature = "clippy")]
{
println!("cargo:warning=Skipping tauri_build during Clippy");
@@ -6,4 +36,6 @@ fn main() {
#[cfg(not(feature = "clippy"))]
tauri_build::build();
Ok(())
}
-1
View File
@@ -12,7 +12,6 @@
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart",
"deep-link:default",
"autostart:allow-enable",
"autostart:allow-disable",
-1
View File
@@ -74,7 +74,6 @@
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"process:allow-restart",
"process:allow-exit",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
+16 -1
View File
@@ -478,7 +478,22 @@ Function .onInit
${EndIf}
!if "${DISPLAYLANGUAGESELECTOR}" == "true"
!insertmacro MUI_LANGDLL_DISPLAY
; Auto-update forwards the app's UI language as `/LANG=<NSIS-lang-id>` so
; the installer uses it directly and skips the interactive language
; selector, letting the update start without prompting the user.
; See `src-tauri/src/core/updater.rs` (`nsis_language_id`).
${GetOptions} $CMDLINE "/LANG=" $0
${IfNot} ${Errors}
${If} $0 == "1033"
${OrIf} $0 == "1049"
${OrIf} $0 == "2052"
StrCpy $LANGUAGE $0
${Else}
!insertmacro MUI_LANGDLL_DISPLAY
${EndIf}
${Else}
!insertmacro MUI_LANGDLL_DISPLAY
${EndIf}
!endif
!insertmacro SetContext
+21 -8
View File
@@ -1,4 +1,4 @@
use super::CmdResult;
use super::{CmdResult, WithErrorCode as _, coded_error};
use crate::feat;
use crate::utils::{dirs, yaml_emitter};
use crate::{
@@ -32,7 +32,9 @@ pub async fn get_clash_info() -> CmdResult<ClashInfo> {
/// 修改Clash配置
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload).await.stringify_err()
feat::patch_clash(&payload)
.await
.with_error_code("CLASH_CONFIG_UPDATE_FAILED")
}
/// 修改Clash模式
@@ -41,7 +43,9 @@ pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
feat::change_clash_mode(payload).await
feat::change_clash_mode(payload)
.await
.with_error_code("CLASH_MODE_UPDATE_FAILED")
}
/// 获取当前 Clash 模式(容错读取)
@@ -74,7 +78,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
let error_msg: String = format!("Core changed but failed to restart: {err}").into();
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
logging!(error, Type::Core, "{error_msg}");
Ok(Some(error_msg))
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
}
}
}
@@ -82,7 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
let error_msg: String = err;
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
Ok(Some(error_msg))
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
}
}
}
@@ -90,7 +94,10 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
/// 启动核心
#[tauri::command]
pub async fn start_core() -> CmdResult {
let result = CoreManager::global().start_core().await.stringify_err();
let result = CoreManager::global()
.start_core()
.await
.with_error_code("CORE_START_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -101,7 +108,10 @@ pub async fn start_core() -> CmdResult {
#[tauri::command]
pub async fn stop_core() -> CmdResult {
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
let result = CoreManager::global().stop_core().await.stringify_err();
let result = CoreManager::global()
.stop_core()
.await
.with_error_code("CORE_STOP_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -112,7 +122,10 @@ pub async fn stop_core() -> CmdResult {
#[tauri::command]
pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
let result = CoreManager::global().restart_core().await.stringify_err();
let result = CoreManager::global()
.restart_core()
.await
.with_error_code("CORE_RESTART_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
+15
View File
@@ -0,0 +1,15 @@
use super::{CmdResult, StringifyErr as _};
use crate::{
core::listener::{ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome},
feat,
};
#[tauri::command]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err()
}
#[tauri::command]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings).await.stringify_err()
}
+31
View File
@@ -3,11 +3,18 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, String>;
const USER_ERROR_PREFIX: &str = "CVR_ERROR:";
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> String {
format!("{USER_ERROR_PREFIX}{code}\n{detail}").into()
}
// Command modules
pub mod app;
pub mod backup;
pub mod clash;
pub mod lightweight;
pub mod listener;
pub mod media_unlock_checker;
pub mod network;
pub mod profile;
@@ -26,6 +33,7 @@ pub use app::*;
pub use backup::*;
pub use clash::*;
pub use lightweight::*;
pub use listener::*;
pub use media_unlock_checker::*;
pub use network::*;
pub use profile::*;
@@ -46,6 +54,10 @@ pub trait StringifyErr<T> {
F: Fn(&str);
}
pub trait WithErrorCode<T> {
fn with_error_code(self, code: &str) -> CmdResult<T>;
}
impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
fn stringify_err(self) -> CmdResult<T> {
self.map_err(|e| e.to_string().into())
@@ -62,3 +74,22 @@ impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
})
}
}
impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
fn with_error_code(self, code: &str) -> CmdResult<T> {
self.map_err(|error| coded_error(code, error))
}
}
#[cfg(test)]
mod tests {
use super::coded_error;
#[test]
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
assert_eq!(
coded_error("CORE_RESTART_FAILED", "connection refused"),
"CVR_ERROR:CORE_RESTART_FAILED\nconnection refused"
);
}
}
+5 -6
View File
@@ -5,7 +5,6 @@ use clash_verge_logging::{Type, logging};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use std::net::TcpListener;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
@@ -60,6 +59,11 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
Ok(map)
}
#[tauri::command]
pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err()
}
/// 获取系统主机名
#[tauri::command]
pub fn get_system_hostname() -> String {
@@ -99,8 +103,3 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
Ok(result)
}
#[tauri::command]
pub fn is_port_in_use(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_err()
}
+134 -43
View File
@@ -1,5 +1,4 @@
use super::CmdResult;
use super::StringifyErr as _;
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles;
use crate::utils::window_manager::WindowManager;
@@ -21,7 +20,6 @@ use clash_verge_logging::{Type, logging, logging_error};
use scopeguard::defer;
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
@@ -61,7 +59,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
}
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
Err(e.to_string().into())
Err(coded_error("PROFILE_ENHANCE_FAILED", e))
}
}
}
@@ -79,18 +77,18 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
}
Err(e) => {
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
return Err(profile_import_error(&e).into());
return Err(coded_error("PROFILE_IMPORT_FAILED", profile_import_error(&e)));
}
};
if let Err(e) = profiles_append_item_safe(item).await {
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
return Err(format!("导入订阅失败: {}", e).into());
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
}
if let Err(e) = profiles_save_file_safe().await {
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
return Err(format!("导入订阅失败: {}", e).into());
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
}
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
logging_error!(Type::Timer, Timer::global().refresh().await);
@@ -114,7 +112,7 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
}
Err(err) => {
logging!(error, Type::Cmd, "重新排序配置文件失败: {}", err);
Err(format!("重新排序配置文件失败: {}", err).into())
Err(coded_error("PROFILE_REORDER_FAILED", err))
}
}
}
@@ -125,7 +123,9 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => {
profiles_save_file_safe().await.stringify_err()?;
profiles_save_file_safe()
.await
.with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await);
// 发送配置变更通知
if let Some(uid) = &item.uid {
@@ -134,10 +134,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
}
Ok(())
}
Err(err) => match err.to_string().as_str() {
"the file already exists" => Err("the file already exists".into()),
_ => Err(format!("add profile error: {err}").into()),
},
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
}
}
@@ -148,7 +145,7 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
Ok(_) => Ok(()),
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
Err(e.to_string().into())
Err(coded_error("PROFILE_UPDATE_FAILED", e))
}
}
}
@@ -157,8 +154,12 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
// 使用Send-safe helper函数
let should_update = profiles_delete_item_safe(&index).await.stringify_err()?;
profiles_save_file_safe().await.stringify_err()?;
let should_update = profiles_delete_item_safe(&index)
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
profiles_save_file_safe()
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
}
@@ -177,15 +178,18 @@ pub async fn delete_profile(index: String) -> CmdResult {
Ok(outcome) => {
logging!(warn, Type::Cmd, "删除订阅后更新配置失败: {}", outcome);
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
return Err(outcome.to_string().into());
return Err(coded_error("PROFILE_DELETE_FAILED", outcome));
}
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
return Err(e.to_string().into());
return Err(coded_error("PROFILE_DELETE_FAILED", e));
}
}
}
Timer::global().refresh().await.stringify_err()?;
Timer::global()
.refresh()
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
Ok(())
}
@@ -276,12 +280,14 @@ async fn handle_update_error<E: std::fmt::Display>(
Ok(ValidationOutcome::invalid_from_message(message))
}
async fn handle_timeout(current_profile: Option<&String>) -> CmdResult<ValidationOutcome> {
let timeout_msg: String = "配置更新超时(30秒),可能是配置验证或核心通信阻塞".into();
logging!(error, Type::Cmd, "{}", timeout_msg);
discard_and_restore(current_profile).await?;
handle::Handle::notice_message("config_validate::timeout", timeout_msg.clone());
Ok(ValidationOutcome::invalid_from_message(timeout_msg))
async fn run_profile_config_update_transition<Update, UpdateFuture>(
update_config: Update,
) -> anyhow::Result<ValidationOutcome>
where
Update: FnOnce() -> UpdateFuture,
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
{
update_config().await
}
async fn perform_config_update(
@@ -291,14 +297,12 @@ async fn perform_config_update(
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result =
tokio::time::timeout(Duration::from_secs(30), CoreManager::global().update_config_forced()).await;
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result {
Ok(Ok(outcome)) if outcome.is_valid() => handle_success(current_value).await,
Ok(Ok(outcome)) => handle_validation_failure(outcome, current_profile).await,
Ok(Err(e)) => handle_update_error(e, current_profile).await,
Err(_) => handle_timeout(current_profile).await,
Ok(outcome) if outcome.is_valid() => handle_success(current_value).await,
Ok(outcome) => handle_validation_failure(outcome, current_profile).await,
Err(e) => handle_update_error(e, current_profile).await,
}
}
@@ -323,7 +327,9 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
perform_config_update(target_profile, previous_profile.as_ref()).await
perform_config_update(target_profile, previous_profile.as_ref())
.await
.map_err(|error| coded_error("PROFILE_SWITCH_FAILED", error))
}
/// 根据profile name修改profiles
@@ -355,7 +361,17 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
false
};
profiles_patch_item_safe(&index, &profile).await.stringify_err()?;
// A selection written from the UI or the chain proxy is newer than anything a restore still
// in flight captured; without this it would be pushed back to the older node moments later.
let records_a_selection = profile.selected.is_some();
profiles_patch_item_safe(&index, &profile)
.await
.with_error_code("PROFILE_UPDATE_FAILED")?;
if records_a_selection {
profiles::supersede_selected_activation();
}
// 如果更新间隔或允许自动更新变更,异步刷新定时器
if should_refresh_timer {
@@ -380,17 +396,22 @@ pub async fn view_profile(index: String) -> CmdResult {
let profiles_ref = profiles.latest_arc();
let file = profiles_ref
.get_item(&index)
.stringify_err()?
.with_error_code("PROFILE_OPEN_FAILED")?
.file
.as_ref()
.ok_or("the file field is null")?;
.ok_or_else(|| coded_error("PROFILE_OPEN_FAILED", "the file field is null"))?;
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
let path = dirs::app_profiles_dir()
.with_error_code("PROFILE_OPEN_FAILED")?
.join(file.as_str());
if !path.exists() {
return CmdResult::Err(format!("file not found \"{}\"", path.display()).into());
return CmdResult::Err(coded_error(
"PROFILE_OPEN_FAILED",
format!("file not found \"{}\"", path.display()),
));
}
help::open_file(path).stringify_err()
help::open_file(path).with_error_code("PROFILE_OPEN_FAILED")
}
/// 读取配置文件内容
@@ -400,23 +421,32 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
PrfItem {
file: profiles_ref.get_item(&index).stringify_err()?.file.to_owned(),
file: profiles_ref
.get_item(&index)
.with_error_code("PROFILE_READ_FAILED")?
.file
.to_owned(),
..Default::default()
}
};
if let Some(file) = item.file.as_ref() {
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
let path = dirs::app_profiles_dir()
.with_error_code("PROFILE_READ_FAILED")?
.join(file.as_str());
match tokio::fs::try_exists(&path).await {
Ok(true) => {}
Ok(false) => return Ok(String::new()),
Err(err) => {
return Err(format!("failed to check profile file \"{}\": {err}", path.display()).into());
return Err(coded_error(
"PROFILE_READ_FAILED",
format!("failed to check profile file \"{}\": {err}", path.display()),
));
}
}
}
let data = item.read_file().await.stringify_err()?;
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data)
}
@@ -430,9 +460,32 @@ pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
#[cfg(test)]
mod tests {
use super::commit_current_profile;
use super::{commit_current_profile, run_profile_config_update_transition};
use crate::config::{IProfiles, PrfItem};
use crate::core::validate::ValidationOutcome;
use clash_verge_draft::Draft;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
use tokio::sync::Barrier;
struct CancellationProbe {
cancelled: Arc<AtomicBool>,
completed: Arc<AtomicBool>,
}
impl Drop for CancellationProbe {
fn drop(&mut self) {
if !self.completed.load(Ordering::Acquire) {
self.cancelled.store(true, Ordering::Release);
}
}
}
fn profile(uid: &str) -> PrfItem {
PrfItem {
@@ -467,4 +520,42 @@ mod tests {
assert!(committed.get_item("new").is_ok());
Ok(())
}
#[tokio::test(start_paused = true)]
async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> {
let update_started = Arc::new(Barrier::new(2));
let release_update = Arc::new(Barrier::new(2));
let update_cancelled = Arc::new(AtomicBool::new(false));
let update_completed = Arc::new(AtomicBool::new(false));
let mut update = Box::pin(run_profile_config_update_transition({
let update_started = Arc::clone(&update_started);
let release_update = Arc::clone(&release_update);
let update_cancelled = Arc::clone(&update_cancelled);
let update_completed = Arc::clone(&update_completed);
move || async move {
let _probe = CancellationProbe {
cancelled: update_cancelled,
completed: Arc::clone(&update_completed),
};
update_started.wait().await;
release_update.wait().await;
update_completed.store(true, Ordering::Release);
Ok(ValidationOutcome::Valid)
}
}));
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
update_started.wait().await;
tokio::time::advance(Duration::from_secs(31)).await;
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
assert!(!update_cancelled.load(Ordering::Acquire));
release_update.wait().await;
assert!(update.await?.is_valid());
assert!(update_completed.load(Ordering::Acquire));
assert!(!update_cancelled.load(Ordering::Acquire));
Ok(())
}
}
+92 -3
View File
@@ -1,12 +1,73 @@
use super::CmdResult;
use crate::core::tray::Tray;
use crate::process::AsyncHandler;
use crate::{
cmd::StringifyErr as _,
config::Config,
core::{
handle::Handle,
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_yaml_ng::Mapping;
use std::{
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
};
/// Record which node a group is on, in the current profile.
///
/// Takes the group and the node rather than the whole selection list, and merges them into the
/// profile on this side. The frontend used to send the list it had rendered, which made two
/// selections made before that list refreshed into one overwriting the other: both were built
/// from the same stale array, and the later write dropped the earlier group's choice. Since a
/// core start re-applies whatever the profile holds, the dropped one then came back on restart.
///
/// The tray already recorded this way. Now there is one way.
#[tauri::command]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
config
.and_then(|config| config.get("proxy-groups"))
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.filter(|name| !name.is_empty() && *name != "GLOBAL")
.filter(|name| seen.insert((*name).to_owned()))
.map(str::to_owned)
.collect()
}
#[tauri::command]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
proxies,
providers: providers.ok(),
}))
}
/// 同步托盘和GUI的代理选择状态
#[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
@@ -50,3 +111,31 @@ 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()]
);
}
}
-22
View File
@@ -12,28 +12,6 @@ pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().await.latest_arc().config.clone())
}
/// 获取运行时代理组顺序
#[tauri::command]
pub async fn get_runtime_proxy_group_order() -> CmdResult<Vec<String>> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
Ok(runtime
.config
.as_ref()
.and_then(|config| config.get("proxy-groups"))
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default())
}
/// 获取运行时YAML配置
#[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> {
+27 -11
View File
@@ -1,35 +1,51 @@
use super::{CmdResult, StringifyErr as _};
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
use super::{CmdResult, WithErrorCode as _};
use crate::core::{
CoreManager,
service::{SERVICE_MANAGER, ServiceStatus},
};
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager.controlled_stop_core_inner().await.with_error_code(error_code)?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
.map_err(|e| format!("{op_type} Service failed: {e}").into())
.with_error_code(error_code)
}
#[tauri::command]
pub async fn install_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::InstallRequired, "Install").await
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
#[tauri::command]
pub async fn uninstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await
CoreManager::global()
.uninstall_service_and_start_sidecar()
.await
.with_error_code("SERVICE_UNINSTALL_FAILED")
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "Reinstall").await
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "Repair").await
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
#[tauri::command]
pub async fn is_service_available() -> CmdResult<bool> {
service::is_service_available().await.stringify_err()?;
Ok(true)
pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar()
.await
.with_error_code("SERVICE_SIDECAR_FAILED")
}
+8 -6
View File
@@ -1,9 +1,11 @@
use std::sync::Arc;
use crate::core::runstate::{RUN_STATE, RunStateView};
use crate::core::{CoreManager, manager::RunningMode};
/// 获取当前内核运行模式
/// The whole Run State in one call.
///
/// Replaces the separate running-mode and service-install-state commands: they were three
/// caches on three refresh intervals describing one fact, which the frontend then had to keep
/// coherent by hand.
#[tauri::command]
pub async fn get_running_mode() -> Result<Arc<RunningMode>, String> {
Ok(CoreManager::global().get_running_mode())
pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view())
}
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::config::Config;
use crate::constants::{network, tun as tun_const};
use crate::utils::dirs::{ipc_path, path_to_str};
use crate::utils::dirs::{path_to_str, sidecar_ipc_path};
use crate::utils::{dirs, help};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
@@ -326,7 +326,7 @@ impl IClashTemp {
pub fn guard_external_controller_ipc() -> String {
// 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致
ipc_path()
sidecar_ipc_path()
.ok()
.and_then(|path| path_to_str(&path).ok().map(|s| s.into()))
.unwrap_or_else(|| {
+94 -22
View File
@@ -1,11 +1,11 @@
use super::{IClashTemp, IProfiles, IVerge};
use crate::{
config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
constants::{files, timing},
core::{
CoreManager,
handle::{self, Handle},
service, tray,
tray,
validate::CoreConfigValidator,
},
enhance,
@@ -18,8 +18,11 @@ use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf};
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use std::{
collections::HashSet,
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
};
use tokio::sync::OnceCell;
use tokio::time::sleep;
@@ -30,6 +33,8 @@ pub struct Config {
runtime_config: Draft<IRuntime>,
}
static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false);
impl Config {
pub async fn global() -> &'static Self {
static CONFIG: OnceCell<Config> = OnceCell::const_new();
@@ -73,34 +78,59 @@ impl Config {
let verge = Self::verge().await.latest_arc();
clash_verge_i18n::sync_locale(verge.language.as_deref());
// init Tun mode
let handle = Handle::app_handle();
let is_admin = is_current_app_handle_admin(handle);
let is_service_available = service::is_service_available().await.is_ok();
if !is_admin && !is_service_available {
let verge = Self::verge().await;
verge.edit_draft(|d| {
d.enable_tun_mode = Some(false);
});
verge.apply();
let _ = tray::Tray::global().update_menu().await;
Ok(())
}
// 分离数据获取和异步调用避免Send问题
let verge_data = Self::verge().await.latest_arc();
logging_error!(Type::Core, verge_data.save_file().await);
}
pub fn tun_suppressed_for_session() -> bool {
TUN_SESSION_SUPPRESSED.load(Ordering::Acquire)
}
pub(crate) async fn suppress_tun_for_session() {
TUN_SESSION_SUPPRESSED.store(true, Ordering::Release);
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
}
pub(crate) async fn restore_tun_for_session() {
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
}
pub(crate) async fn disable_tun_and_persist() -> Result<()> {
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
let verge = Self::verge().await;
verge.edit_draft(|draft| {
draft.enable_tun_mode = Some(false);
});
verge.apply();
verge.data_arc().save_file().await?;
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
Ok(())
}
pub async fn init_runtime_config() -> Result<()> {
let validation_result = Self::generate_and_validate().await?;
let fallback_applied = match Self::resolve_startup_mixed_port().await {
Ok(applied) => applied,
Err(error) => {
Self::block_startup_core(&error);
return Err(error);
}
};
let validation_result = if fallback_applied {
None
} else {
Self::generate_and_validate().await?
};
if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await;
handle::Handle::notice_message(msg_type, msg_content);
}
Self::runtime().await.apply();
{
let profiles = Self::profiles().await.data_arc();
// Logging error internally
@@ -113,13 +143,26 @@ impl Config {
// Ensure "Merge" and "Script" profile items exist, adding them if missing.
async fn ensure_default_profile_items() -> Result<()> {
let profiles = Self::profiles().await;
Self::ensure_default_profile_items_for(&profiles).await
}
async fn ensure_default_profile_items_for(profiles: &Draft<IProfiles>) -> Result<()> {
if profiles.latest_arc().get_items().is_none() {
logging!(
warn,
Type::Config,
"Profile items 无法加载,跳过默认项初始化以保留现有配置文件"
);
return Ok(());
}
if profiles.latest_arc().get_item("Merge").is_err() {
let merge_item = &mut PrfItem::from_merge(Some("Merge".into()))?;
profiles_append_item_safe(merge_item).await?;
profiles_append_item_to_safe(profiles, merge_item).await?;
}
if profiles.latest_arc().get_item("Script").is_err() {
let script_item = &mut PrfItem::from_script(Some("Script".into()))?;
profiles_append_item_safe(script_item).await?;
profiles_append_item_to_safe(profiles, script_item).await?;
}
Ok(())
}
@@ -217,6 +260,10 @@ impl Config {
}
pub async fn verify_config_initialization() {
if Self::startup_core_block_reason().is_some() {
return;
}
let backoff = ExponentialBuilder::default()
.with_min_delay(std::time::Duration::from_millis(100))
.with_max_delay(std::time::Duration::from_secs(2))
@@ -369,4 +416,29 @@ mod tests {
let box_iruntime_size = std::mem::size_of_val(&draft);
assert_eq!(box_iruntime_size, std::mem::size_of::<Draft<Box<IRuntime>>>());
}
#[tokio::test]
async fn failed_profile_index_survives_startup_without_cleanup() -> Result<()> {
let profiles = Draft::new(IProfiles::default());
let profiles_dir = std::env::temp_dir().join(format!("clash-verge-profile-cleanup-{}", nanoid::nanoid!()));
tokio::fs::create_dir_all(&profiles_dir).await?;
let active_profile = profiles_dir.join("Ractive.yaml");
tokio::fs::write(&active_profile, "proxies: []").await?;
Config::ensure_default_profile_items_for(&profiles).await?;
profiles.data_arc().cleanup_orphaned_files_in(&profiles_dir).await?;
let profile_was_preserved = tokio::fs::try_exists(&active_profile).await?;
tokio::fs::remove_dir_all(&profiles_dir).await?;
assert!(
profile_was_preserved,
"startup must not delete profiles when profiles.yaml could not be loaded"
);
assert!(
profiles.data_arc().get_items().is_none(),
"startup must not replace an unreadable profile index with defaults"
);
Ok(())
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Resolving the Mixed Port.
//!
//! Two different questions were being asked with one answer:
//!
//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that
//! writes configuration or decides what to start needs this, and it has to be answerable
//! before the Core exists.
//! - *What is the Core actually serving on?* — what the Core reports, else what we configured.
//! Anything that points a client at the proxy wants this, because the two can differ: a
//! startup fallback moves the port, and a hand-edited Core config can move it too.
//!
//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config
//! entirely) and the frontend hand-copied a version of the second.
use anyhow::Result;
use super::Config;
use crate::core::handle::Handle;
/// The port mihomo listens on when nothing else is configured.
pub const DEFAULT_MIXED_PORT: u16 = 7897;
/// Resolving the Mixed Port.
pub struct MixedPort;
impl MixedPort {
/// The Mixed Port the app has configured.
///
/// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe
/// to call before the Core is running, unlike [`Self::effective`].
///
/// Reading the draft means a caller that hands this to something *outside* the app — the
/// PAC endpoint is the one that does — can name a port the Core has not moved to yet.
/// What makes that safe is that the only path staging a listener port,
/// `feat::listener::save_proxy_ports`, closes PAC across the staging with `core_starting`
/// and reopens it with `core_start_settled`. A second path that stages a listener port
/// while the Core is serving would have to do the same.
pub async fn desired() -> u16 {
let selected = Config::verge().await.latest_arc().verge_mixed_port;
// `get_mixed_port` already falls back to the default when the Merge Config is silent.
let merged = Config::clash().await.latest_arc().get_mixed_port();
resolve_desired(selected, merged)
}
/// The Mixed Port the Core is actually serving on.
///
/// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on
/// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked,
/// which is the right answer whenever the Core is not running anyway.
pub async fn effective() -> u16 {
let desired = Self::desired().await;
resolve_effective(
|| async { Ok(Handle::mihomo().get_base_config().await?.mixed_port) },
desired,
)
.await
}
}
/// Prefer what the user selected, else what the Merge Config resolved to.
const fn resolve_desired(selected: Option<u16>, merged: u16) -> u16 {
match selected {
Some(port) => port,
None => merged,
}
}
/// Prefer what the Core reports; fall back to what we configured.
async fn resolve_effective<Live, LiveFuture>(live: Live, desired: u16) -> u16
where
Live: FnOnce() -> LiveFuture,
LiveFuture: Future<Output = Result<u16>>,
{
match live().await {
// A Core reporting port 0 is a Core that has not bound anything yet.
Ok(port) if port != 0 => port,
_ => desired,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::*;
#[test]
fn a_selected_port_wins_over_the_merge_config() {
assert_eq!(resolve_desired(Some(9000), 7897), 9000);
}
#[test]
fn the_merge_config_answers_when_nothing_is_selected() {
assert_eq!(resolve_desired(None, 8080), 8080);
}
#[tokio::test]
async fn a_reporting_core_overrides_what_we_configured() {
// This is the startup-fallback case: we asked for 7897 and the Core landed on 7898.
assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898);
}
#[tokio::test]
async fn an_unreachable_core_leaves_the_configured_port_standing() {
assert_eq!(
resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await,
7897
);
}
#[tokio::test]
async fn a_core_that_has_not_bound_yet_is_not_believed() {
assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897);
}
}
+4 -1
View File
@@ -2,12 +2,15 @@ mod clash;
#[allow(clippy::module_inception)]
mod config;
mod encrypt;
mod mixed_port;
mod port;
mod prfitem;
pub mod profiles;
pub mod runtime;
pub(crate) mod snapshot;
mod verge;
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
pub use self::{clash::*, config::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
+333
View File
@@ -0,0 +1,333 @@
use super::{
Config, ConfigType, IClashTemp, IVerge,
snapshot::{capture_config_files, restore_files},
};
use crate::{
constants::timing,
core::{
handle::Handle,
listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys},
owner_identity::current_owner_credentials,
service::{SERVICE_MANAGER, ServiceStatus},
validate::CoreConfigValidator,
},
process::AsyncHandler,
utils::port::find_next_available_port,
};
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_yaml_ng::Value;
use std::{
collections::HashSet,
net::SocketAddr,
str::FromStr as _,
sync::atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Copy)]
struct MixedPortFallback {
original: u16,
current: u16,
}
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
pub(crate) async fn retry_startup_mixed_port_fallback() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
async fn resolve_startup_mixed_port_inner() -> Result<bool> {
let clash = Self::clash().await.latest_arc();
let verge = Self::verge().await.latest_arc();
let selected_port = clash.get_mixed_port();
let bind_scope =
ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?;
if owned_service_core_uses_port(selected_port).await {
logging!(
info,
Type::Setup,
"Mixed proxy port {} belongs to the current user's managed core; skipping fallback",
selected_port
);
return Ok(false);
}
let selected_scope = bind_scope.clone();
let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port))
.await
.context("mixed proxy port probe task failed")?;
if !port_in_use {
return Ok(false);
}
let reserved = configured_listener_ports(&clash, &verge);
let candidate = AsyncHandler::spawn_blocking(move || {
find_next_available_port(selected_port, &reserved, |port| {
bind_scope.mixed_port_is_available(port)
})
})
.await
.context("mixed proxy fallback scan task failed")?
.ok_or_else(|| anyhow!("no eligible mixed proxy port is available"))?;
Self::apply_startup_mixed_port_fallback(selected_port, candidate).await?;
Ok(true)
}
async fn apply_startup_mixed_port_fallback(old_port: u16, new_port: u16) -> Result<()> {
let clash = Self::clash().await;
let verge = Self::verge().await;
let runtime = Self::runtime().await;
// Every failure below leaves the three layers as they were, without saying so.
let transaction = DraftTransaction::begin(vec![&clash, &verge, &runtime])?;
clash.edit_draft(|draft| {
draft.0.insert(MIXED_PORT_KEY.into(), new_port.into());
});
verge.edit_draft(|draft| {
draft.verge_mixed_port = Some(new_port);
});
Self::generate()
.await
.context("failed to materialize runtime configuration with fallback port")?;
let validation = CoreConfigValidator::global()
.validate_config_outcome()
.await
.context("failed to validate runtime configuration with fallback port")?;
if !validation.is_valid() {
bail!("runtime configuration with fallback port is invalid: {validation}");
}
let snapshots = capture_config_files().await?;
let candidate_clash = clash.latest_arc();
let candidate_verge = verge.latest_arc();
let persist_result = async {
candidate_clash
.save_config()
.await
.context("failed to persist Application Merge Configuration")?;
candidate_verge
.save_file()
.await
.context("failed to persist selected mixed proxy port")?;
Self::generate_file(ConfigType::Run)
.await
.context("failed to persist Runtime Configuration")?;
Ok::<(), anyhow::Error>(())
}
.await;
// Files are not part of the transaction, so a failed write is restored explicitly.
// The drafts are rolled back first: restoring reads the committed configuration, and
// must not see a candidate that is being abandoned.
if let Err(error) = persist_result {
transaction.rollback();
return match restore_files(&snapshots).await {
Ok(()) => Err(error),
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
};
}
transaction.commit();
record_fallback(old_port, new_port);
Handle::refresh_clash();
Handle::refresh_verge();
logging!(
warn,
Type::Config,
"Mixed proxy port {} is externally occupied; switched to {} and persisted all configuration layers",
old_port,
new_port
);
Ok(())
}
pub(crate) fn block_startup_core(error: &anyhow::Error) {
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string());
}
pub(crate) fn startup_core_block_reason() -> Option<String> {
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
STARTUP_CORE_BLOCK_REASON.lock().clone()
} else {
None
}
}
pub(crate) fn notify_startup_mixed_port_fallback() {
let Some(change) = PENDING_FALLBACK_NOTICE.lock().take() else {
return;
};
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message(
"mixed_port::fallback",
format!("{},{}", change.original, change.current),
);
});
}
}
fn record_fallback(old_port: u16, new_port: u16) {
let mut pending = PENDING_FALLBACK_NOTICE.lock();
match pending.as_mut() {
Some(change) => change.current = new_port,
None => {
*pending = Some(MixedPortFallback {
original: old_port,
current: new_port,
});
}
}
}
fn report_fallback_error(message: String) {
logging!(
error,
Type::Config,
"Automatic mixed proxy port fallback failed: {}",
message
);
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message("mixed_port::fallback_error", message);
});
}
// Only service-managed cores can survive into this startup phase; this app has not spawned its sidecar yet.
async fn owned_service_core_uses_port(port: u16) -> bool {
if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
return false;
}
let credentials = match current_owner_credentials() {
Ok(credentials) => credentials,
Err(error) => {
logging!(
warn,
Type::Service,
"Unable to identify current service owner while checking mixed proxy port: {error:#}"
);
return false;
}
};
let response = match clash_verge_service_ipc::get_status(&credentials).await {
Ok(response) => response,
Err(error) => {
logging!(
warn,
Type::Service,
"Unable to query current service owner while checking mixed proxy port: {error:#}"
);
return false;
}
};
let Some(status) = response.data else {
return false;
};
if response.code > 0 || !status.is_active || status.core_pid.is_none() {
return false;
}
match Handle::mihomo().get_base_config().await {
Ok(config) => config.mixed_port == port,
Err(error) => {
logging!(
warn,
Type::Service,
"Current user's service core is active but its mixed proxy port is unavailable: {error}; \
preserving the selected port until core replacement resolves ownership"
);
true
}
}
}
fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet<u16> {
let mut ports = HashSet::new();
// Every listener except the Mixed Port itself: that is the one being reassigned, so
// colliding with its current value is exactly what we are trying to do.
for key in proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY) {
if let Some(port) = mapping_port(&clash.0, key) {
ports.insert(port);
}
}
if let Ok(controller) = SocketAddr::from_str(IClashTemp::guard_external_controller(&clash.0).as_str()) {
ports.insert(controller.port());
}
ports.extend([verge.verge_socks_port, verge.verge_port].into_iter().flatten());
#[cfg(not(target_os = "windows"))]
if let Some(port) = verge.verge_redir_port {
ports.insert(port);
}
#[cfg(target_os = "linux")]
if let Some(port) = verge.verge_tproxy_port {
ports.insert(port);
}
ports
}
fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option<u16> {
mapping.get(key).and_then(|value| match value {
Value::String(port) => port.parse().ok(),
Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
_ => None,
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod listener_key_tests {
use super::{MIXED_PORT_KEY, proxy_listener_keys};
#[test]
fn the_reserved_set_covers_every_listener_but_the_one_being_moved() {
let reserved: Vec<_> = proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY).collect();
assert!(
!reserved.contains(&MIXED_PORT_KEY),
"the port being reassigned must not reserve itself"
);
assert_eq!(
reserved.len(),
proxy_listener_keys().count() - 1,
"every other listener must be reserved, so a new one cannot be forgotten here"
);
}
}
#[cfg(test)]
mod tests {
use super::configured_listener_ports;
use crate::config::{IClashTemp, IVerge};
#[test]
fn configured_ports_include_disabled_listener_assignments() {
let ports = configured_listener_ports(&IClashTemp::template(), &IVerge::template());
assert!(ports.contains(&7898));
assert!(ports.contains(&7899));
assert!(ports.contains(&9097));
#[cfg(not(target_os = "windows"))]
assert!(ports.contains(&7895));
#[cfg(target_os = "linux")]
assert!(ports.contains(&7896));
}
}
+379 -11
View File
@@ -10,6 +10,7 @@ use crate::{
},
};
use anyhow::{Context as _, Result, bail};
use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -22,7 +23,7 @@ use std::{
LazyLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
time::{Duration, Instant},
};
use tauri_plugin_mihomo::models::{Proxies, ProxyType};
use tokio::{fs, task::JoinHandle};
@@ -47,6 +48,21 @@ static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0);
// lock acquisition, connection-pool waiting, and local-socket connection establishment.
const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1);
/// How long a restore keeps trying to put back selections the core has not loaded yet.
///
/// A provider-backed group is present but empty until its provider finishes loading, which on a
/// cold start is exactly when restoring runs. Bounded rather than indefinite because a record
/// naming a node the profile genuinely no longer has looks identical from here, and would
/// otherwise be retried for the life of the process.
const SELECTED_NODES_SETTLE_DEADLINE: Duration = Duration::from_secs(30);
/// How often a restore looks again while waiting for those groups.
const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
/// How long a start waits for the selections that *can* be put back before carrying on.
///
/// Covers a healthy core answering one query and a handful of selects. Past that the start
/// proceeds: the remaining groups need the core to finish loading, which is not something a
/// start can usefully wait for.
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
/// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
@@ -419,11 +435,27 @@ impl IProfiles {
/// 以 app 中的 profile 列表为准,删除不再需要的文件
pub async fn cleanup_orphaned_files(&self) -> Result<()> {
let profiles_dir = dirs::app_profiles_dir()?;
self.cleanup_orphaned_files_in(&profiles_dir).await
}
pub(super) async fn cleanup_orphaned_files_in(&self, profiles_dir: &Path) -> Result<()> {
if !profiles_dir.exists() {
return Ok(());
}
// 如果 items 为空(例如 profiles.yaml 解析失败导致
// IProfiles::new() 返回 default),此时无法判断哪些文件是活跃的,
// 跳过清理以避免误删用户正在使用的订阅配置文件。
// See: https://github.com/clash-verge-rev/clash-verge-rev/issues/7577
if self.items.as_ref().is_none_or(|v| v.is_empty()) {
logging!(
warn,
Type::Config,
"Profile items 为空,跳过孤儿文件清理以避免误删活跃的配置文件"
);
return Ok(());
}
// 获取所有 active profile 的文件名集合
let active_files = self.get_all_active_files();
@@ -571,8 +603,12 @@ pub async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file_data:
}
pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
Config::profiles()
.await
let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await
}
pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, item: &mut PrfItem) -> Result<()> {
profiles
.with_data_modify(|mut profiles| async move {
profiles.append_item(item).await?;
Ok((profiles, ()))
@@ -751,6 +787,21 @@ fn reconcile_selected_nodes(
plan
}
/// Abandon any activation still in flight.
///
/// An activation reads the profile, then polls the core until its groups are readable, then puts
/// its selections. A choice the user makes inside that window is newer than what the activation
/// captured, so letting it finish would push the core back to the older node — and the profile,
/// already holding the newer one, would then disagree with the core. Bumping the generation is
/// how an activation is told it has been overtaken.
///
/// Called *after* the profile is written, which is the only placement that needs to exist: an
/// activation that read the older profile is cancelled by this, and one starting afterwards reads
/// the newer one and needs no cancelling.
pub fn supersede_selected_activation() {
ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel);
}
fn is_activation_current(generation: u64) -> bool {
ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) == generation
}
@@ -758,7 +809,7 @@ fn is_activation_current(generation: u64) -> bool {
async fn fetch_proxies_with_timeout() -> Result<Proxies> {
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
loop {
match handle::Handle::mihomo().await.get_proxies().await {
match handle::Handle::mihomo().get_proxies().await {
Ok(proxies) => return proxies,
Err(err) => {
logging!(debug, Type::Config, "mihomo proxies are not ready yet: {err}");
@@ -773,10 +824,7 @@ async fn fetch_proxies_with_timeout() -> Result<Proxies> {
async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<()> {
tokio::time::timeout(MIHOMO_OPERATION_TIMEOUT, async {
handle::Handle::mihomo()
.await
.select_node_for_group(group_name, node)
.await
handle::Handle::mihomo().select_node_for_group(group_name, node).await
})
.await
.with_context(|| format!("timed out while selecting node [{node}] for group [{group_name}]"))?
@@ -850,6 +898,59 @@ async fn update_tray_after_activation(generation: u64) {
}
}
/// Record which node a group is on, in the current profile.
///
/// The counterpart of the frontend's `useRecordSelection`, for the selections the backend makes
/// on the user's behalf — the tray is the one that does. What the profile holds is what
/// [`activate_selected_nodes`] re-applies when a core starts, so a selection it never learned
/// about is one the next start silently undoes.
pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
let recorded = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
}
entry.now = Some(node);
}
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
}
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if recorded {
// Newer than anything an activation still in flight captured.
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
}
async fn persist_reconciled_selected(
profile_uid: &String,
original_selected: &[PrfSelected],
@@ -890,10 +991,104 @@ async fn persist_reconciled_selected(
Ok(())
}
/// The recorded selections the core is not currently on.
///
/// Deliberately "is the core on it" rather than "did we send a select": a group whose provider
/// has not loaded is present but empty, a `select` can fail while the core is still settling, and
/// a group that was already correct never produces an activation at all. Only the running state
/// tells those apart.
fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<String> {
selected
.iter()
.filter_map(|item| {
let (Some(group_name), Some(node)) = (&item.name, &item.now) else {
return None;
};
match proxies.proxies.get(group_name.as_str()) {
Some(group) if group.now.as_deref() == Some(node.as_str()) => None,
_ => Some(group_name.clone()),
}
})
.collect()
}
/// Keep putting back the selections the core could not be moved to yet.
///
/// The single re-check a profile switch needs is enough there, because a switch happens once the
/// configuration is loaded. A core start is the opposite case: the groups a restore is trying to
/// put back may not exist yet. Without this the restore reported success having applied nothing,
/// and the group stayed on the first entry of its `proxies:` list — the outcome restoring exists
/// to prevent.
///
/// Only ever reached with [`SelectionRepair::KeepRecords`]: a profile switch is entitled to
/// conclude that a group it cannot see is gone, so it has nothing to wait for.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
loop {
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
if !is_activation_current(generation) {
return;
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
// Unreachable core: the deadline still applies, so this cannot spin forever.
if Instant::now() >= deadline {
return;
}
continue;
};
if !is_activation_current(generation) {
return;
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return;
}
if Instant::now() >= deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return;
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, generation)
.await
.is_none()
{
return;
}
if is_activation_current(generation) {
handle::Handle::refresh_clash();
}
}
}
/// Fires once the selections that could be applied have been.
///
/// Dropping it without firing releases the waiter too: every way the worker can end early is a
/// way of saying "nothing more is coming", and a start that waits forever for one of them would
/// be a worse failure than the one this exists to prevent.
struct FirstPassSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl FirstPassSignal {
fn notify(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
async fn activate_selected_nodes_worker(
profile_uid: String,
selected: Vec<PrfSelected>,
generation: u64,
repair: SelectionRepair,
mut first_pass_done: FirstPassSignal,
) -> Result<()> {
let first_snapshot = fetch_proxies_with_timeout().await?;
if !is_activation_current(generation) {
@@ -951,6 +1146,14 @@ async fn activate_selected_nodes_worker(
return Ok(());
}
if repair == SelectionRepair::KeepRecords {
// Everything the core could be moved to has been. Whoever is waiting to point traffic
// here may go; what is left needs the core to finish loading and cannot be waited on.
first_pass_done.notify();
settle_pending_selections(&selected, &mut completed_activations, generation).await;
return Ok(());
}
if plan.repaired_count > 0 && is_activation_current(generation) {
logging!(
info,
@@ -964,13 +1167,64 @@ async fn activate_selected_nodes_worker(
Ok(())
}
/// Whether an activation may also prune the records it cannot match.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SelectionRepair {
/// Drop records whose group or node the core does not have. Only safe once the configuration
/// they belong to is known to be fully loaded, which is true right after a profile switch.
Prune,
/// Apply what can be applied and leave the records alone.
///
/// What a core start needs. A provider-backed group can be empty or absent for seconds after
/// the core comes up, and a record pruned on that evidence is gone from `profiles.yaml` for
/// good — the provider finishing later cannot bring it back.
KeepRecords,
}
pub fn activate_selected_nodes() -> Result<()> {
// The first-pass signal is for callers that wait; a profile switch does not, and dropping
// the receiver simply makes the send a no-op.
drop(activate_selected_nodes_with(SelectionRepair::Prune));
Ok(())
}
/// Put the profile's selections back before anything is pointed at the core.
///
/// Awaited, unlike a profile switch's activation, because the caller enables the system proxy as
/// soon as this returns: a proxy pointed at a core still sitting on the first entry of every
/// group is the outcome restoring exists to prevent. Bounded so that a core which will not answer
/// delays a start rather than blocking it — the selections keep being retried either way, and
/// being proxied through the wrong node beats not being proxied at all.
///
/// TUN is deliberately not covered, because it cannot be: it is configured in the core's own
/// file, so it is carrying traffic from the moment the process starts, before anything here could
/// run. What puts a TUN user on the right node from the first packet is the core's own
/// `cache.db`, which is why the service keeps an owner's runtime generation across restarts.
pub async fn restore_selected_nodes() {
let first_pass = activate_selected_nodes_with(SelectionRepair::KeepRecords);
if tokio::time::timeout(SELECTED_NODES_FIRST_PASS_BUDGET, first_pass)
.await
.is_err()
{
logging!(
warn,
Type::Config,
"starting without having put the selected nodes back yet; still trying"
);
}
}
fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot::Receiver<()> {
logging!(info, Type::Config, "starting activating selected nodes");
let mut active_task = ACTIVATE_SELECTED_TASK.lock();
let generation = ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
let previous_task = active_task.take();
let (first_pass_sender, first_pass_done) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
// Held for the whole task so that every exit — superseded, no profile, an error — drops
// it and releases anyone waiting. Only the worker fires it deliberately.
let first_pass = FirstPassSignal(Some(first_pass_sender));
if let Some(previous_task) = previous_task {
let _ = previous_task.await;
}
@@ -979,7 +1233,12 @@ pub fn activate_selected_nodes() -> Result<()> {
}
let result = async {
let profiles = Config::profiles().await.latest_arc();
// Committed, not the draft. A profile switch stages its target before validating
// it, and a switch that then fails discards that draft — but an activation which had
// already read it would go on to apply the rejected profile's selections to the one
// still running. The switch that succeeds commits before it activates, so this is the
// same value there.
let profiles = Config::profiles().await.data_arc();
let current = profiles.get_current().context("no current profile running")?.clone();
let selected = profiles
.get_item(&current)
@@ -994,7 +1253,7 @@ pub fn activate_selected_nodes() -> Result<()> {
}
return Ok(());
}
activate_selected_nodes_worker(current, selected, generation).await
activate_selected_nodes_worker(current, selected, generation, repair, first_pass).await
}
.await;
@@ -1010,7 +1269,7 @@ pub fn activate_selected_nodes() -> Result<()> {
});
*active_task = Some(handle);
drop(active_task);
Ok(())
first_pass_done
}
#[cfg(test)]
@@ -1045,6 +1304,115 @@ mod tests {
}
}
#[test]
fn a_group_the_core_has_not_loaded_is_still_unsettled() {
// The regression this pins. A provider-backed group is present but empty until its
// provider loads, which on a cold start is exactly when restoring runs. Reconciling
// produces no activation for it, so a restore that stopped after one re-check reported
// success having left the group on the first entry of its `proxies:` list.
let saved = vec![selected("provider-group", "saved")];
let unloaded = proxies(vec![("provider-group", &[], None)]);
assert!(
reconcile_selected_nodes(&saved, Some(&unloaded), &unloaded)
.activations
.is_empty(),
"nothing can be activated while the group is empty"
);
assert_eq!(
unsettled_selections(&saved, &unloaded),
vec![String::from("provider-group")],
"so the restore must keep looking rather than call it done"
);
}
#[test]
fn a_group_the_core_is_already_on_is_settled() {
let saved = vec![selected("Proxy", "Node A")];
let loaded = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node A"))]);
assert!(
unsettled_selections(&saved, &loaded).is_empty(),
"a group already on its node needs nothing, even though it produces no activation"
);
}
#[test]
fn a_group_on_the_wrong_node_is_unsettled() {
// Covers a `select` that failed transiently: the group is loaded, the node exists, and
// the core is simply not on it. Retrying is the only thing that fixes that.
let saved = vec![selected("Proxy", "Node A")];
let wrong = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node B"))]);
assert_eq!(unsettled_selections(&saved, &wrong), vec![String::from("Proxy")]);
}
#[test]
fn a_record_without_a_group_or_node_is_not_waited_on() {
// A malformed record cannot be satisfied by waiting, and holding the settle loop open
// for it would delay giving up on everything else.
let malformed = vec![
PrfSelected {
name: Some("Proxy".into()),
now: None,
},
PrfSelected {
name: None,
now: Some("Node A".into()),
},
];
assert!(unsettled_selections(&malformed, &proxies(vec![])).is_empty());
}
#[test]
fn a_group_missing_from_both_snapshots_is_dropped_from_the_records() {
// This is the pruning a profile switch wants and a core start must not do: the record is
// gone from the plan, and `persist_reconciled_selected` writes the plan back. Pinned here
// because it is why `SelectionRepair` exists — the predicate is right, the question is
// only who is entitled to act on it.
let saved = vec![selected("provider-group", "saved")];
let empty = proxies(vec![]);
let plan = reconcile_selected_nodes(&saved, Some(&empty), &empty);
assert!(
plan.selected.is_empty(),
"a group absent from both looks invalid, so the record is dropped"
);
assert_eq!(plan.repaired_count, 1, "and dropping it counts as a repair");
}
#[test]
fn a_group_missing_from_only_the_second_snapshot_is_kept() {
// Absent once is not evidence: only a group that was already absent when the first
// snapshot was taken is treated as gone.
let saved = vec![selected("provider-group", "saved")];
let had_it = proxies(vec![("provider-group", &["saved"], Some("saved"))]);
let lost_it = proxies(vec![]);
let plan = reconcile_selected_nodes(&saved, Some(&had_it), &lost_it);
assert_eq!(plan.selected, saved);
assert_eq!(plan.repaired_count, 0);
}
#[test]
fn superseding_an_activation_makes_the_one_in_flight_stand_down() {
// What a selection made during a start relies on: the restore that is still polling the
// core has to notice it has been overtaken, or it will push the older node back.
let generation = ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) + 1;
ACTIVATE_SELECTED_GENERATION.store(generation, Ordering::Release);
assert!(is_activation_current(generation));
supersede_selected_activation();
assert!(
!is_activation_current(generation),
"an activation that has been overtaken must stop before it puts anything"
);
}
#[test]
fn keeps_valid_selection_and_activates_when_needed() {
let saved = vec![selected("group", "saved")];
+91
View File
@@ -0,0 +1,91 @@
use crate::utils::dirs;
use anyhow::{Context as _, Result, bail};
use std::{io::ErrorKind, path::PathBuf};
pub(crate) struct FileSnapshot {
path: PathBuf,
content: Option<Vec<u8>>,
}
async fn capture_files(paths: &[PathBuf]) -> Result<Vec<FileSnapshot>> {
let mut snapshots = Vec::with_capacity(paths.len());
for path in paths {
let content = match tokio::fs::read(path).await {
Ok(content) => Some(content),
Err(error) if error.kind() == ErrorKind::NotFound => None,
Err(error) => {
return Err(error).with_context(|| format!("failed to snapshot {}", path.display()));
}
};
snapshots.push(FileSnapshot {
path: path.clone(),
content,
});
}
Ok(snapshots)
}
pub(crate) async fn capture_config_files() -> Result<Vec<FileSnapshot>> {
let runtime_path = dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG);
capture_files(&[dirs::clash_path()?, dirs::verge_path()?, runtime_path]).await
}
pub(crate) async fn restore_files(snapshots: &[FileSnapshot]) -> Result<()> {
let mut errors = Vec::new();
for snapshot in snapshots {
let result = match &snapshot.content {
Some(content) => tokio::fs::write(&snapshot.path, content).await,
None => match tokio::fs::remove_file(&snapshot.path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
},
};
if let Err(error) = result {
errors.push(format!("{}: {}", snapshot.path.display(), error));
}
}
if errors.is_empty() {
Ok(())
} else {
bail!("{}", errors.join("; "))
}
}
#[cfg(test)]
mod tests {
use super::{capture_files, restore_files};
#[tokio::test]
async fn restores_every_configuration_layer_after_partial_write() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!(
"config-rollback-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
tokio::fs::create_dir_all(&root).await?;
let paths = [
root.join("config.yaml"),
root.join("verge.yaml"),
root.join("clash-verge.yaml"),
];
for (index, path) in paths.iter().enumerate() {
tokio::fs::write(path, format!("original-{index}")).await?;
}
let snapshots = capture_files(&paths).await?;
tokio::fs::write(&paths[0], "changed").await?;
tokio::fs::write(&paths[1], "changed").await?;
tokio::fs::remove_file(&paths[2]).await?;
restore_files(&snapshots).await?;
for (index, path) in paths.iter().enumerate() {
assert_eq!(tokio::fs::read_to_string(path).await?, format!("original-{index}"));
}
tokio::fs::remove_dir_all(root).await?;
Ok(())
}
}
-4
View File
@@ -556,10 +556,6 @@ impl IVerge {
patch!(enable_external_controller);
}
pub const fn get_singleton_port() -> u16 {
crate::constants::network::ports::SINGLETON_SERVER
}
/// 获取日志等级
pub fn get_log_level(&self) -> LevelFilter {
if let Some(level) = self.app_log_level.as_ref() {
+4 -5
View File
@@ -11,11 +11,6 @@ pub mod network {
pub const DEFAULT_MIXED: u16 = 7897;
pub const DEFAULT_SOCKS: u16 = 7898;
pub const DEFAULT_HTTP: u16 = 7899;
#[cfg(not(feature = "verge-dev"))]
pub const SINGLETON_SERVER: u16 = 33331;
#[cfg(feature = "verge-dev")]
pub const SINGLETON_SERVER: u16 = 11233;
}
}
@@ -25,6 +20,10 @@ pub mod timing {
pub const CONFIG_UPDATE_DEBOUNCE: Duration = Duration::from_millis(300);
pub const STARTUP_ERROR_DELAY: Duration = Duration::from_secs(2);
// How long a re-asked staging request is worth waiting for. One that already landed answers in
// milliseconds; anything slower means the Service is still working, so ReplaceCore beats waiting.
pub const STAGE_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
// Windows 服务冷启动较慢,避免过早回退 sidecar。
#[cfg(target_os = "windows")]
pub const SERVICE_WAIT_MAX: Duration = Duration::from_millis(30000);
+1 -1
View File
@@ -265,7 +265,7 @@ impl WebDavClient {
fn parse_webdav_list(xml: &str) -> Result<Vec<ListEntity>, reqwest_dav::Error> {
// Some WebDAV servers emit RFC 2822's numeric UTC offset instead of HTTP-date's `GMT`.
let normalized_xml = xml.replace(" +0000</", " GMT</");
let multi_status: ListMultiStatus = serde_xml_rs::from_str(&normalized_xml)?;
let multi_status: ListMultiStatus = reqwest_dav::re_exports::serde_xml_rs::from_str(&normalized_xml)?;
multi_status.responses.into_iter().map(ListEntity::try_from).collect()
}
+17 -3
View File
@@ -3,7 +3,6 @@ use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::AppHandle;
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
use tokio::sync::RwLockReadGuard;
use super::notification::{FrontendEvent, NotificationSystem};
@@ -32,8 +31,8 @@ impl Handle {
APP_HANDLE.get().expect("App handle not initialized")
}
pub async fn mihomo() -> RwLockReadGuard<'static, Mihomo> {
Self::app_handle().mihomo().read().await
pub fn mihomo() -> &'static Mihomo {
Self::app_handle().mihomo()
}
pub fn refresh_clash() {
@@ -48,6 +47,17 @@ impl Handle {
Self::send_event(FrontendEvent::RefreshProfiles);
}
/// Push a Run State snapshot to the frontend.
///
/// Sent on every transition, so the frontend does not have to poll to notice that the Core
/// stopped or that the Service came back.
pub fn notify_run_state(state: &crate::core::runstate::RunStateView) {
let Ok(state) = serde_json::to_value(state) else {
return;
};
Self::send_event(FrontendEvent::RunStateChanged { state });
}
pub fn notify_profile_changed(profile_id: &String) {
Self::send_event(FrontendEvent::ProfileChanged {
current_profile_id: profile_id,
@@ -80,6 +90,10 @@ impl Handle {
self.is_exiting.store(true, Ordering::Release);
}
pub fn clear_is_exiting(&self) {
self.is_exiting.store(false, Ordering::Release);
}
pub fn is_exiting(&self) -> bool {
self.is_exiting.load(Ordering::Acquire)
}
+604
View File
@@ -0,0 +1,604 @@
use anyhow::{Context as _, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
use std::{
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr as _,
};
/// The config key of the Mixed Port, named once so callers stop spelling it out.
pub(crate) const MIXED_PORT_KEY: &str = "mixed-port";
const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [
(
MIXED_PORT_KEY,
"mixed",
&[ListenerTransport::Tcp, ListenerTransport::Udp],
),
("socks-port", "socks", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
("port", "http", &[ListenerTransport::Tcp]),
("redir-port", "redir", &[ListenerTransport::Tcp]),
(
"tproxy-port",
"tproxy",
&[ListenerTransport::Tcp, ListenerTransport::Udp],
),
];
/// The config key of every proxy listener.
///
/// Callers that need "every listener except one" filter this rather than keeping their own
/// list, so adding a listener cannot be half-applied.
pub(crate) fn proxy_listener_keys() -> impl Iterator<Item = &'static str> {
PROXY_LISTENERS.iter().map(|(key, _, _)| *key)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyPortSettings {
pub mixed_port: u16,
pub socks: ToggleableProxyPort,
pub http: ToggleableProxyPort,
pub redir: ToggleableProxyPort,
pub tproxy: ToggleableProxyPort,
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct ToggleableProxyPort {
pub enabled: bool,
pub port: u16,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListenerProbe {
pub address: String,
pub transports: Vec<ListenerTransport>,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ListenerTransport {
Tcp,
Udp,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ListenerProbeOutcome {
Available,
Conflict { port: u16, transport: ListenerTransport },
Invalid { message: String },
Indeterminate { message: String },
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum SaveProxyPortsOutcome {
Saved,
Conflict { port: u16, transport: ListenerTransport },
}
#[derive(Clone, Debug)]
pub(crate) struct ListenerBindScope {
addresses: Vec<IpAddr>,
}
impl ListenerBindScope {
pub(crate) fn from_mapping(config: &Mapping) -> Result<Self> {
Ok(Self {
addresses: proxy_bind_addresses(config)?,
})
}
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
let claims = self
.addresses
.iter()
.copied()
.flat_map(|address| {
[ListenerTransport::Tcp, ListenerTransport::Udp]
.into_iter()
.map(move |transport| BindClaim::new("mixed", address, port, transport))
})
.collect::<Vec<_>>();
matches!(probe_claims(&claims), ListenerProbeOutcome::Available)
}
}
impl ProxyPortSettings {
pub(crate) fn validate(&self) -> Result<()> {
if self.mixed_port == 0 {
bail!("mixed proxy port must be between 1 and 65535");
}
for (name, listener) in [
("SOCKS", self.socks),
("HTTP", self.http),
("redir", self.redir),
("TProxy", self.tproxy),
] {
if listener.port == 0 {
bail!("{name} proxy port must be between 1 and 65535");
}
}
Ok(())
}
}
struct BindClaim {
name: &'static str,
address: IpAddr,
port: u16,
transport: ListenerTransport,
}
impl BindClaim {
const fn new(name: &'static str, address: IpAddr, port: u16, transport: ListenerTransport) -> Self {
Self {
name,
address,
port,
transport,
}
}
const fn socket_addr(&self) -> SocketAddr {
SocketAddr::new(self.address, self.port)
}
}
pub(crate) fn probe_listener(request: &ListenerProbe) -> ListenerProbeOutcome {
let (addresses, port) = match parse_listener_address(&request.address) {
Ok(address) => address,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: error.to_string(),
};
}
};
let mut transports = Vec::with_capacity(request.transports.len());
for transport in request.transports.iter().copied() {
if !transports.contains(&transport) {
transports.push(transport);
}
}
if transports.is_empty() {
return ListenerProbeOutcome::Invalid {
message: "listener transport list cannot be empty".into(),
};
}
let claims = addresses
.into_iter()
.flat_map(|address| {
transports
.iter()
.copied()
.map(move |transport| BindClaim::new("listener", address, port, transport))
})
.collect::<Vec<_>>();
probe_claims(&claims)
}
pub(crate) fn probe_proxy_port_change(
current: &Mapping,
candidate: &Mapping,
current_core_is_running: bool,
) -> ListenerProbeOutcome {
let current_claims = if current_core_is_running {
match proxy_claims(current) {
Ok(claims) => claims,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: format!("current proxy listener configuration is invalid: {error:#}"),
};
}
}
} else {
Vec::new()
};
let mut candidate_claims = match proxy_claims(candidate) {
Ok(claims) => claims,
Err(error) => {
return ListenerProbeOutcome::Invalid {
message: format!("candidate proxy listener configuration is invalid: {error:#}"),
};
}
};
if let Some(conflict) = internal_conflict(&candidate_claims) {
return conflict;
}
// A running core releases its proxy sockets during the coordinated restart.
candidate_claims.retain(|candidate| !current_claims.iter().any(|current| claims_overlap(current, candidate)));
probe_claims(&candidate_claims)
}
fn proxy_claims(config: &Mapping) -> Result<Vec<BindClaim>> {
let addresses = proxy_bind_addresses(config)?;
let mut claims = Vec::new();
for (key, name, transports) in PROXY_LISTENERS {
let Some(port) = mapping_port(config, key)? else {
continue;
};
for address in &addresses {
for transport in transports {
claims.push(BindClaim::new(name, *address, port, *transport));
}
}
}
Ok(claims)
}
fn proxy_bind_addresses(config: &Mapping) -> Result<Vec<IpAddr>> {
let ipv6 = config.get("ipv6").and_then(Value::as_bool).unwrap_or(true);
let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
if !allow_lan {
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
}
let bind_address = config
.get("bind-address")
.and_then(Value::as_str)
.map(str::trim)
.filter(|address| !address.is_empty())
.unwrap_or("*");
if bind_address == "*" {
let mut addresses = vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)];
if ipv6 {
addresses.push(IpAddr::V6(Ipv6Addr::UNSPECIFIED));
}
return Ok(addresses);
}
if bind_address.eq_ignore_ascii_case("localhost") {
return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
}
let normalized = bind_address
.strip_prefix('[')
.and_then(|address| address.strip_suffix(']'))
.unwrap_or(bind_address);
Ok(vec![
IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
])
}
fn parse_listener_address(address: &str) -> Result<(Vec<IpAddr>, u16)> {
let address = address.trim();
if let Ok(socket) = SocketAddr::from_str(address) {
if socket.port() == 0 {
bail!("listener port must be between 1 and 65535");
}
return Ok((vec![socket.ip()], socket.port()));
}
let (host, port) = address
.rsplit_once(':')
.ok_or_else(|| anyhow!("listener address must include a port"))?;
let port = port
.parse::<u16>()
.with_context(|| format!("invalid listener port {port:?}"))?;
if port == 0 {
bail!("listener port must be between 1 and 65535");
}
let host = host
.trim()
.strip_prefix('[')
.and_then(|host| host.strip_suffix(']'))
.unwrap_or_else(|| host.trim());
if host.eq_ignore_ascii_case("localhost") {
return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
}
Ok((
vec![IpAddr::from_str(host).with_context(|| format!("invalid listener host {host:?}"))?],
port,
))
}
fn mapping_port(config: &Mapping, key: &str) -> Result<Option<u16>> {
let Some(value) = config.get(key) else {
return Ok(None);
};
let port = match value {
Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
Value::String(port) => port.parse().ok(),
_ => None,
}
.ok_or_else(|| anyhow!("{key} must be an integer between 1 and 65535"))?;
if port == 0 {
bail!("{key} must be an integer between 1 and 65535");
}
Ok(Some(port))
}
fn internal_conflict(claims: &[BindClaim]) -> Option<ListenerProbeOutcome> {
for (index, claim) in claims.iter().enumerate() {
if claims[..index].iter().any(|existing| claims_overlap(existing, claim)) {
return Some(conflict_outcome(claim));
}
}
None
}
fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool {
left.port == right.port
&& left.transport == right.transport
&& left.address.is_ipv4() == right.address.is_ipv4()
&& (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified())
}
fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
let mut sockets = Vec::with_capacity(claims.len());
for claim in claims {
match bind_claim(claim) {
Ok(socket) => sockets.push(socket),
Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim),
Err(error)
if matches!(
error.kind(),
io::ErrorKind::AddrNotAvailable | io::ErrorKind::InvalidInput | io::ErrorKind::Unsupported
) =>
{
return ListenerProbeOutcome::Invalid {
message: format!(
"{} cannot listen on {} over {}: {}",
claim.name,
claim.socket_addr(),
transport_name(claim.transport),
error
),
};
}
Err(error) => {
return ListenerProbeOutcome::Indeterminate {
message: format!(
"unable to verify {} on {} over {}: {}",
claim.name,
claim.socket_addr(),
transport_name(claim.transport),
error
),
};
}
}
}
debug_assert_eq!(sockets.len(), claims.len());
ListenerProbeOutcome::Available
}
fn bind_claim(claim: &BindClaim) -> io::Result<Socket> {
let domain = if claim.address.is_ipv4() {
Domain::IPV4
} else {
Domain::IPV6
};
let (socket_type, protocol) = match claim.transport {
ListenerTransport::Tcp => (Type::STREAM, Protocol::TCP),
ListenerTransport::Udp => (Type::DGRAM, Protocol::UDP),
};
let socket = Socket::new(domain, socket_type, Some(protocol))?;
if claim.address.is_ipv6() {
socket.set_only_v6(true)?;
}
socket.set_reuse_address(false)?;
#[cfg(windows)]
set_exclusive_address_use(&socket)?;
socket.bind(&SockAddr::from(claim.socket_addr()))?;
if claim.transport == ListenerTransport::Tcp {
socket.listen(1)?;
}
Ok(socket)
}
fn is_bind_conflict(error: &io::Error) -> bool {
if error.kind() == io::ErrorKind::AddrInUse {
return true;
}
#[cfg(windows)]
{
use windows_sys::Win32::Networking::WinSock::WSAEACCES;
error.raw_os_error() == Some(WSAEACCES)
}
#[cfg(not(windows))]
false
}
#[cfg(windows)]
fn set_exclusive_address_use(socket: &Socket) -> io::Result<()> {
use std::{mem::size_of, os::windows::io::AsRawSocket as _};
use windows_sys::Win32::Networking::WinSock::{SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, setsockopt};
let enabled = 1i32;
// Rust exposes `RawSocket` as `u64` for compatibility, while WinSock's
// `SOCKET` is pointer-sized. The value originated from WinSock, so it fits
// in `SOCKET` on every Windows target.
let raw_socket = socket.as_raw_socket() as SOCKET;
// SAFETY: the socket is valid and `enabled` remains alive for the duration of the call.
let result = unsafe {
setsockopt(
raw_socket,
SOL_SOCKET,
SO_EXCLUSIVEADDRUSE,
(&raw const enabled).cast(),
size_of::<i32>() as i32,
)
};
if result == SOCKET_ERROR {
return Err(io::Error::last_os_error());
}
Ok(())
}
const fn conflict_outcome(claim: &BindClaim) -> ListenerProbeOutcome {
ListenerProbeOutcome::Conflict {
port: claim.port,
transport: claim.transport,
}
}
const fn transport_name(transport: ListenerTransport) -> &'static str {
match transport {
ListenerTransport::Tcp => "TCP",
ListenerTransport::Udp => "UDP",
}
}
#[cfg(test)]
mod tests {
use super::{
ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener,
probe_proxy_port_change,
};
use serde_json::json;
use serde_yaml_ng::Mapping;
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
fn mapping(yaml: &str) -> anyhow::Result<Mapping> {
Ok(serde_yaml_ng::from_str(yaml)?)
}
#[test]
fn probe_reports_tcp_conflicts_on_the_requested_address() -> anyhow::Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Tcp],
});
assert_eq!(
outcome,
ListenerProbeOutcome::Conflict {
port,
transport: ListenerTransport::Tcp
}
);
Ok(())
}
#[test]
fn probe_reports_udp_conflicts_on_the_requested_address() -> anyhow::Result<()> {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = socket.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Udp],
});
assert_eq!(
outcome,
ListenerProbeOutcome::Conflict {
port,
transport: ListenerTransport::Udp
}
);
Ok(())
}
#[test]
fn probe_does_not_treat_invalid_addresses_as_available() {
let outcome = probe_listener(&ListenerProbe {
address: "not-a-listener".into(),
transports: vec![ListenerTransport::Tcp],
});
assert!(matches!(outcome, ListenerProbeOutcome::Invalid { .. }));
}
#[test]
fn localhost_matches_mihomo_ipv4_resolution() -> anyhow::Result<()> {
let (addresses, port) = super::parse_listener_address("localhost:7890")?;
assert_eq!(addresses, vec![std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)]);
assert_eq!(port, 7890);
let proxy_addresses =
super::proxy_bind_addresses(&mapping("allow-lan: true\nbind-address: localhost\nipv6: true\n")?)?;
assert_eq!(proxy_addresses, addresses);
Ok(())
}
#[test]
fn proxy_probe_skips_sockets_owned_by_the_running_core() -> anyhow::Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let runtime = mapping(&format!("ipv6: false\nmixed-port: {port}\n"))?;
assert_eq!(
probe_proxy_port_change(&runtime, &runtime, true),
ListenerProbeOutcome::Available
);
assert!(matches!(
probe_proxy_port_change(&runtime, &runtime, false),
ListenerProbeOutcome::Conflict { .. }
));
Ok(())
}
#[test]
fn proxy_probe_rejects_duplicate_candidate_ports() -> anyhow::Result<()> {
let current = mapping("ipv6: false\nmixed-port: 31000\n")?;
let candidate = mapping("ipv6: false\nmixed-port: 31001\nsocks-port: 31001\n")?;
assert_eq!(
probe_proxy_port_change(&current, &candidate, true),
ListenerProbeOutcome::Conflict {
port: 31001,
transport: ListenerTransport::Tcp
}
);
Ok(())
}
#[test]
fn proxy_probe_checks_udp_for_mixed_and_socks_ports() -> anyhow::Result<()> {
for key in ["mixed-port", "socks-port"] {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = socket.local_addr()?.port();
// Exclude the running core's TCP claim so this assertion isolates UDP
// without depending on process-global TCP port availability.
let current = mapping(&format!("ipv6: false\nport: {port}\n"))?;
let candidate = mapping(&format!("ipv6: false\n{key}: {port}\n"))?;
assert_eq!(
probe_proxy_port_change(&current, &candidate, true),
ListenerProbeOutcome::Conflict {
port,
transport: ListenerTransport::Udp
}
);
}
Ok(())
}
#[test]
fn startup_scope_uses_the_runtime_lan_binding() -> anyhow::Result<()> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let scope =
ListenerBindScope::from_mapping(&mapping("allow-lan: true\nbind-address: 0.0.0.0\nipv6: false\n")?)?;
assert!(!scope.mixed_port_is_available(port));
Ok(())
}
#[test]
fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = socket.local_addr()?.port();
let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?;
assert!(!scope.mixed_port_is_available(port));
Ok(())
}
#[test]
fn proxy_settings_keep_the_camel_case_command_contract() -> anyhow::Result<()> {
let settings = serde_json::from_value::<super::ProxyPortSettings>(json!({
"mixedPort": 7897,
"socks": { "enabled": false, "port": 7898 },
"http": { "enabled": false, "port": 7899 },
"redir": { "enabled": false, "port": 7895 },
"tproxy": { "enabled": false, "port": 7896 }
}))?;
assert_eq!(settings.mixed_port, 7897);
Ok(())
}
}
+28 -20
View File
@@ -8,7 +8,6 @@ use std::{
use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::WriterConfig;
use compact_str::CompactString;
use flexi_logger::{
Cleanup, Criterion, DeferredNow, FileSpec, LogSpecBuilder, LogSpecification, LoggerHandle,
@@ -18,11 +17,15 @@ use log::{Level, LevelFilter, Record};
use parking_lot::{Mutex, RwLock};
use crate::{
core::service,
core::{CoreManager, manager::RunningMode, service},
singleton,
utils::dirs::{self, service_log_dir, sidecar_log_dir},
utils::dirs::{self, sidecar_log_dir},
};
const fn should_sync_service_writer(running_mode: RunningMode) -> bool {
matches!(running_mode, RunningMode::Service)
}
pub struct Logger {
handle: Arc<Mutex<Option<LoggerHandle>>>,
sidecar_file_writer: Arc<RwLock<Option<FileLogWriter>>>,
@@ -91,7 +94,10 @@ impl Logger {
filter_modules.push("tauri");
#[cfg(feature = "tracing")]
filter_modules.extend(["tauri_plugin_mihomo", "kode_bridge"]);
let logger = logger.filter(Box::new(clash_verge_logging::NoModuleFilter(filter_modules)));
let logger = logger.filter(Box::new(clash_verge_logging::ModuleFilter::new(
filter_modules,
Some(vec!["tauri_plugin_mihomo"]),
)));
let handle = logger.start()?;
*self.handle.lock() = Some(handle);
@@ -179,15 +185,17 @@ impl Logger {
let sidecar_writer = self.generate_sidecar_writer()?;
*self.sidecar_file_writer.write() = Some(sidecar_writer);
// update service writer config
if service::is_service_ipc_path_exists() && service::is_service_available().await.is_ok() {
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
clash_verge_service_ipc::update_writer(&WriterConfig {
directory: service_log_dir,
// The service writer is auxiliary to the local logger. Synchronize it only
// for an active service session and do not roll back local settings on failure.
if should_sync_service_writer(*CoreManager::global().get_running_mode())
&& let Err(error) = service::update_writer_by_service(&clash_verge_service_ipc::WriterConfig {
directory: String::new(),
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
})
.await?;
.await
{
logging!(warn, Type::Service, "failed to update service writer config: {error:#}");
}
Ok(())
@@ -225,17 +233,17 @@ impl Logger {
logging!(error, Type::System, "failed to get sidecar file log writer");
}
}
}
pub fn service_writer_config(&self) -> Result<WriterConfig> {
let service_log_dir = dirs::path_to_str(&service_log_dir()?)?.into();
let log_max_size = self.log_max_size.load(Ordering::SeqCst);
let log_max_count = self.log_max_count.load(Ordering::SeqCst);
let writer_config = WriterConfig {
directory: service_log_dir,
max_log_size: log_max_size * 1024,
max_log_files: log_max_count,
};
#[cfg(test)]
mod tests {
use super::should_sync_service_writer;
use crate::core::manager::RunningMode;
Ok(writer_config)
#[test]
fn service_writer_sync_requires_service_running_mode() {
assert!(should_sync_service_writer(RunningMode::Service));
assert!(!should_sync_service_writer(RunningMode::Sidecar));
assert!(!should_sync_service_writer(RunningMode::NotRunning));
}
}
+470 -41
View File
@@ -1,4 +1,5 @@
use super::CoreManager;
use super::{CoreManager, RunningMode};
use crate::core::service::StageRequest;
use crate::{
config::{Config, ConfigType, runtime::IRuntime},
constants::timing,
@@ -9,12 +10,154 @@ use crate::{
utils::{dirs, help},
};
use anyhow::{Result, anyhow};
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::StageRuntimeOutcome;
use scopeguard::defer;
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf, time::Instant};
use std::{
collections::HashSet,
path::PathBuf,
time::{Duration, Instant},
};
use tauri_plugin_mihomo::Error as MihomoError;
/// What came back from asking the Service to stage a runtime.
///
/// Separated from the decision below so the decision stays a pure function of it. The variants are
/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude:
/// a refusal is the Service's verdict on this bundle, while silence says nothing at all.
#[derive(Debug, Clone, PartialEq, Eq)]
enum StageAttempt {
/// The Service that owns the running Core predates staging.
Unsupported,
/// The Service answered, and its answer was a refusal about the bundle itself — the kind a
/// fresh start would run into again, because it would materialise the same bundle.
RefusedTheBundle(String),
/// The Service answered with a refusal about something other than the bundle: the session, the
/// protocol, the request. Starting is how several of those get resolved, so it is not a reason
/// to leave the Core where it is.
RefusedForAnotherReason(String),
/// The request did not come back. The Service is authoritative about its own runtime, so
/// without an answer nothing may be assumed about what it did or did not write.
Unanswered(String),
Answered(StageRuntimeOutcome),
}
/// How the Core should be made to pick up a configuration.
#[derive(Debug, PartialEq, Eq)]
enum ConfigApplication {
/// Point the running Core at this path.
ReloadFrom(String),
/// Stop the Core and start it again from a freshly materialised runtime.
ReplaceCore,
/// Leave the Core alone and report why the configuration cannot be applied.
Fail(String),
}
/// Decide how to apply a configuration, given how staging went.
///
/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a
/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start
/// materialises the same bundle — so it would fail the same way, having stopped a Core that was
/// working.
///
/// The distinction matters more than it looks. A refusal about the session is not a refusal about
/// the bundle: starting proposes a new session rather than presenting the old one, and the restart
/// path is also what notices that ownership was lost and clears the system proxy. Treating every
/// non-zero code alike would skip that.
///
/// Everything else — an older Service, a request that never came back, or a Service that asked to
/// be restarted — replaces the Core, which is what this did before staging existed and what the
/// Service guarantees is still safe after every way of declining.
fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication {
match attempt {
StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => {
ConfigApplication::ReloadFrom(config_path.into())
}
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) => {
logging!(
info,
Type::Core,
"Service declined to stage the runtime ({reason:?}); replacing the core instead"
);
ConfigApplication::ReplaceCore
}
StageAttempt::RefusedTheBundle(message) => ConfigApplication::Fail(message.clone()),
StageAttempt::RefusedForAnotherReason(message) => {
logging!(
warn,
Type::Core,
"Service refused to stage the runtime ({message}); replacing the core instead"
);
ConfigApplication::ReplaceCore
}
StageAttempt::Unanswered(error) => {
logging!(
warn,
Type::Core,
"Failed to stage the service runtime, replacing the core instead: {error}"
);
ConfigApplication::ReplaceCore
}
StageAttempt::Unsupported => ConfigApplication::ReplaceCore,
}
}
/// One round trip to the Service, classified but not judged.
async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
match crate::core::service::stage_runtime_by_service(path).await {
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
Ok(StageRequest::Refused { code, message }) => {
let message = message.to_string().into();
if StageRequest::is_about_the_bundle(code) {
StageAttempt::RefusedTheBundle(message)
} else {
StageAttempt::RefusedForAnotherReason(message)
}
}
Err(err) => StageAttempt::Unanswered(format!("{err:#}").into()),
}
}
/// Ask once, and ask again only when the first attempt came back silent.
///
/// Silence is the one answer that has to be resolved rather than acted on. The Service commits the
/// generation before it replies, so a reply that never arrives can leave `config.yaml` already
/// replaced while this side concludes nothing happened — and the Service restarts a Core that dies
/// later from exactly that file. Treating silence as "nothing happened" is therefore a guess, and
/// the expensive half of it is silent: the Core keeps running the previous configuration until
/// something restarts it, and then it does not.
///
/// Asking again is cheap precisely where it matters. Staging plans against the manifest it wrote,
/// so a bundle that already landed copies nothing and answers in milliseconds; and because the
/// Service holds its lifecycle lock for the whole operation, a second ask that overtakes a first
/// one still in flight waits for it rather than interleaving with it. `confirm_within` is what
/// keeps that wait from being the caller's problem — past it, the answer has stopped being worth
/// more than the fallback, which is where a second silence goes anyway.
async fn stage_with_confirmation<Ask, Fut>(confirm_within: Duration, ask: Ask) -> StageAttempt
where
Ask: Fn() -> Fut,
Fut: std::future::Future<Output = StageAttempt>,
{
let first = match ask().await {
StageAttempt::Unanswered(first) => first,
answered => return answered,
};
logging!(
warn,
Type::Core,
"Staging did not answer ({first}); asking once more before replacing the core"
);
match tokio::time::timeout(confirm_within, ask()).await {
Ok(StageAttempt::Unanswered(again)) => {
StageAttempt::Unanswered(format!("{first}; asked again: {again}").into())
}
Ok(answered) => answered,
Err(_) => StageAttempt::Unanswered(format!("{first}; the second ask did not answer either").into()),
}
}
impl CoreManager {
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
use crate::constants::files::RUNTIME_CONFIG;
@@ -92,13 +235,15 @@ impl CoreManager {
}
async fn perform_config_update(&self) -> Result<ValidationOutcome> {
let runtime = Config::runtime().await;
let transaction = DraftTransaction::begin(vec![&runtime])?;
if let Err(err) = Config::generate().await {
let message: String = err.to_string().into();
Config::runtime().await.discard();
return Ok(ValidationOutcome::invalid_from_message(message));
}
self.apply_generate_config_inner().await
self.validate_and_apply(transaction).await
}
pub(crate) async fn update_runtime_config<F>(&self, f: F) -> Result<ValidationOutcome>
@@ -113,59 +258,343 @@ impl CoreManager {
self.finish_config_update();
}
Config::runtime().await.edit_draft(f);
self.apply_generate_config_inner().await
let runtime = Config::runtime().await;
let transaction = DraftTransaction::begin(vec![&runtime])?;
runtime.edit_draft(f);
self.validate_and_apply(transaction).await
}
async fn apply_generate_config_inner(&self) -> Result<ValidationOutcome> {
match CoreConfigValidator::global().validate_config_outcome().await {
Ok(outcome) if outcome.is_valid() => {
let run_path = Config::generate_file(ConfigType::Run).await?;
self.apply_config(run_path).await?;
Ok(ValidationOutcome::Valid)
}
Ok(outcome) => {
Config::runtime().await.discard();
Ok(outcome)
}
Err(e) => {
Config::runtime().await.discard();
Err(e)
}
/// Validate the staged Runtime Config and hand it to the Core, committing only if both work.
///
/// Takes the transaction rather than opening one, so it covers the staging its callers did.
/// Every way out of here other than the last line rolls that staging back.
async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result<ValidationOutcome> {
let outcome = CoreConfigValidator::global().validate_config_outcome().await?;
if !outcome.is_valid() {
return Ok(outcome);
}
let run_path = Config::generate_file(ConfigType::Run).await?;
self.apply_config(run_path).await?;
transaction.commit();
Ok(ValidationOutcome::Valid)
}
/// Hand the generated configuration to the Core.
///
/// Says nothing about drafts: whether the staged Runtime Config is kept follows from
/// whether this succeeded, and the caller's transaction decides that.
///
/// Both modes have the same shape — put the configuration where the Core can read it, ask the
/// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar
/// mode the Core already reads the app's own directory, while in Service mode the Service has
/// to materialise the configuration into the directory it started the Core in.
async fn apply_config(&self, path: PathBuf) -> Result<()> {
let path = dirs::path_to_str(&path)?;
match self.reload_config(path).await {
Ok(_) => {
Config::runtime().await.apply();
logging!(info, Type::Core, "Configuration applied");
Ok(())
if matches!(*self.get_running_mode(), RunningMode::Service) {
let _lifecycle = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Service) {
return Err(anyhow!("core mode changed while applying service configuration"));
}
Err(err) => {
return self.apply_config_by_service(&path).await;
}
let path = dirs::path_to_str(&path)?;
self.reload_or_restart(path).await
}
/// Apply a configuration in Service mode, staging it in place when that is possible.
///
/// Every way of not staging leads to the same place it always led: stop the Core and start it
/// again from a freshly materialised runtime. That is what makes declining safe — the Service
/// leaves the runtime it could not stage exactly as the running Core left it.
///
/// Caller must hold `lifecycle_lock`.
async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> {
match plan_config_application(&self.attempt_staging(path).await) {
ConfigApplication::Fail(message) => {
// The Service looked at this bundle and would not take it. Replacing the Core would
// hand it the same bundle, so the outage would buy nothing.
logging!(
warn,
Type::Core,
"Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}"
"Service refused the runtime, leaving the core running: {message}"
);
match self.restart_core().await {
Ok(_) => {
Config::runtime().await.apply();
logging!(info, Type::Core, "Configuration applied after restart");
Ok(())
}
Err(err) => {
logging!(error, Type::Core, "Failed to restart core: {}", err);
Config::runtime().await.discard();
Err(anyhow!("Failed to apply config: {}", err))
}
return Err(anyhow!("{message}"));
}
ConfigApplication::ReloadFrom(staged) => match self.reload_config(&staged).await {
Ok(()) => {
logging!(info, Type::Core, "Configuration staged and applied by service");
return Ok(());
}
Err(err) => logging!(
warn,
Type::Core,
"Failed to reload the staged service runtime, replacing the core instead: {err}"
),
},
ConfigApplication::ReplaceCore => {}
}
self.replace_service_core_with_config(path).await?;
logging!(info, Type::Core, "Configuration materialized and applied by service");
Ok(())
}
/// Ask the Service to stage the runtime, reporting the attempt rather than judging it.
async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt {
if !crate::core::service::active_service_supports_runtime_staging() {
return StageAttempt::Unsupported;
}
stage_with_confirmation(timing::STAGE_CONFIRM_TIMEOUT, || ask_to_stage(path)).await
}
/// Reload the Core from `path`, and replace the Core if it will not take it.
async fn reload_or_restart(&self, path: &str) -> Result<()> {
let Err(err) = self.reload_config(path).await else {
logging!(info, Type::Core, "Configuration applied");
return Ok(());
};
logging!(
warn,
Type::Core,
"Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}"
);
match self.restart_core().await {
Ok(_) => {
logging!(info, Type::Core, "Configuration applied after restart");
Ok(())
}
Err(err) => {
logging!(error, Type::Core, "Failed to restart core: {}", err);
Err(anyhow!("Failed to apply config: {}", err))
}
}
}
async fn reload_config(&self, path: &str) -> Result<(), MihomoError> {
handle::Handle::mihomo().await.reload_config(true, path).await
handle::Handle::mihomo().reload_config(true, path).await
}
}
#[cfg(test)]
mod tests {
use super::{ConfigApplication, StageAttempt, StageRequest, plan_config_application, stage_with_confirmation};
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
use std::{cell::Cell, time::Duration};
const CONFIRM_WITHIN: Duration = Duration::from_secs(5);
fn staged() -> StageAttempt {
StageAttempt::Answered(StageRuntimeOutcome::Staged {
config_path: "/service/runtime.generation-1/config.yaml".to_owned(),
})
}
#[test]
fn a_staged_runtime_is_reloaded_from_where_the_service_put_it() {
let attempt = StageAttempt::Answered(StageRuntimeOutcome::Staged {
config_path: "/service/runtime.generation-1/config.yaml".to_owned(),
});
assert_eq!(
plan_config_application(&attempt),
ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()),
"the core must be pointed at the service's copy, never at the app's own"
);
}
/// Wrap a rejection as the answer the Service would have sent.
///
/// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service
/// to decline cannot be added upstream without this failing to compile — which is what sends
/// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached
/// production without one, and inherited its verdict silently.
fn declined(reason: StageRejection) -> StageAttempt {
match &reason {
StageRejection::CoreNotRunning
| StageRejection::CorePathChanged
| StageRejection::RuntimeUnwritable { .. }
| StageRejection::CoreRestarted => {}
}
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason })
}
#[test]
fn a_service_that_declined_makes_the_core_be_replaced() {
for reason in [
StageRejection::CoreNotRunning,
StageRejection::CorePathChanged,
StageRejection::RuntimeUnwritable {
detail: "held open".to_owned(),
},
// The service's own watchdog can restart the core mid-staging, which leaves the
// generation holding a mixture no manifest describes. Rebuilding it from nothing is
// the only answer that does not guess at what is in there.
StageRejection::CoreRestarted,
] {
assert_eq!(
plan_config_application(&declined(reason.clone())),
ConfigApplication::ReplaceCore,
"declining is an outcome, not an error: {reason:?} must fall back, not fail"
);
}
}
#[test]
fn a_service_too_old_to_stage_makes_the_core_be_replaced() {
assert_eq!(
plan_config_application(&StageAttempt::Unsupported),
ConfigApplication::ReplaceCore,
"an installed service without staging must keep working, not demand a reinstall"
);
}
#[test]
fn a_refusal_about_the_bundle_leaves_the_core_alone() {
// Replacing the core would materialise the same bundle the Service just rejected, so the
// outage buys nothing: the configuration update fails either way, and this way the user's
// proxy is still working when it does.
assert_eq!(
plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())),
ConfigApplication::Fail("runtime asset is unavailable".into())
);
}
#[test]
fn a_refusal_about_anything_else_still_replaces_the_core() {
// A stale session is the case that matters: starting proposes a new one, so it is the cure
// rather than a repeat of the failure — and the restart path is also what notices ownership
// was lost and clears the system proxy.
assert_eq!(
plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())),
ConfigApplication::ReplaceCore
);
}
#[test]
fn only_bundle_codes_count_as_a_refusal_of_the_bundle() {
use clash_verge_service_ipc::ServiceErrorCode;
assert!(StageRequest::is_about_the_bundle(
ServiceErrorCode::InvalidRuntimeAsset as u16
));
assert!(StageRequest::is_about_the_bundle(
ServiceErrorCode::InvalidInstallLocation as u16
));
for other in [
ServiceErrorCode::StaleOwnerSession,
ServiceErrorCode::UnauthorizedOwner,
ServiceErrorCode::ProtocolMismatch,
ServiceErrorCode::NotActive,
] {
assert!(
!StageRequest::is_about_the_bundle(other as u16),
"{other:?} says nothing about the bundle, so starting must still be tried"
);
}
}
#[test]
fn a_request_that_never_came_back_makes_the_core_be_replaced() {
// Not knowing what the service did is the one case where reloading would be a guess: the
// staged path might not exist, or might hold the previous configuration.
assert_eq!(
plan_config_application(&StageAttempt::Unanswered("connection reset".into())),
ConfigApplication::ReplaceCore
);
}
#[tokio::test]
async fn an_answer_is_taken_at_its_word_and_not_asked_for_twice() {
// Staging is only idempotent in what it writes, not in what it costs: a second ask after a
// perfectly good answer would re-send the whole bundle for nothing.
for answer in [
staged(),
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired {
reason: StageRejection::CoreNotRunning,
}),
StageAttempt::RefusedTheBundle("runtime asset is unavailable".into()),
StageAttempt::RefusedForAnotherReason("owner session is stale".into()),
] {
let asks = Cell::new(0);
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
asks.set(asks.get() + 1);
std::future::ready(answer.clone())
})
.await;
assert_eq!(outcome, answer);
assert_eq!(asks.get(), 1, "{answer:?} was answered, so asking again buys nothing");
}
}
#[tokio::test]
async fn silence_is_resolved_by_asking_again_rather_than_assumed() {
// The case this exists for: the service committed the generation and the reply was lost.
// Asking again reaches a service that has already done the work, so the second answer is
// the truth the first one failed to deliver — and it keeps the core off the replace path.
let asks = Cell::new(0);
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
asks.set(asks.get() + 1);
std::future::ready(match asks.get() {
1 => StageAttempt::Unanswered("connection reset".into()),
_ => staged(),
})
})
.await;
assert_eq!(
outcome,
staged(),
"the second answer must decide, not the first silence"
);
assert_eq!(asks.get(), 2);
assert_eq!(
plan_config_application(&outcome),
ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()),
"resolving the silence is only worth doing if it changes what happens next"
);
}
#[tokio::test]
async fn a_second_silence_still_replaces_the_core() {
let asks = Cell::new(0);
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || {
asks.set(asks.get() + 1);
std::future::ready(StageAttempt::Unanswered("connection reset".into()))
})
.await;
assert_eq!(asks.get(), 2, "asking a third time would just be a retry loop");
assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore);
assert!(
matches!(&outcome, StageAttempt::Unanswered(detail)
if detail.contains("connection reset") && detail.contains("asked again")),
"two silences must stay unanswered and record that both were tried, got {outcome:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_second_ask_that_keeps_working_is_not_waited_out() {
// A service still copying assets is why the deadline exists: the answer would be worth
// having, but not at the cost of leaving the user's configuration unapplied indefinitely.
let asks = Cell::new(0);
let started = tokio::time::Instant::now();
let outcome = stage_with_confirmation(CONFIRM_WITHIN, || async {
asks.set(asks.get() + 1);
match asks.get() {
1 => StageAttempt::Unanswered("deadline has elapsed".into()),
_ => std::future::pending().await,
}
})
.await;
assert_eq!(plan_config_application(&outcome), ConfigApplication::ReplaceCore);
assert_eq!(
started.elapsed(),
CONFIRM_WITHIN,
"the caller must be released at the deadline, not at the service's convenience"
);
}
}
File diff suppressed because it is too large Load Diff
+171 -23
View File
@@ -5,24 +5,56 @@ mod state;
use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logger::AsyncLogger;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use std::{
fmt,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Instant,
};
use tauri_plugin_shell::process::CommandChild;
use crate::core::runstate::{RUN_STATE, RealEnv, RunStateStore};
use crate::singleton;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
#[derive(Debug, serde::Serialize, PartialEq, Eq)]
const CORE_READINESS_ACTIVE_BIT: u64 = 1;
const CORE_READINESS_GENERATION_STEP: u64 = 1 << 1;
const fn next_active_core_readiness_state(state: u64) -> u64 {
(state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT) | CORE_READINESS_ACTIVE_BIT
}
const fn inactive_core_readiness_state(state: u64) -> u64 {
state.wrapping_add(CORE_READINESS_GENERATION_STEP) & !CORE_READINESS_ACTIVE_BIT
}
const fn active_core_readiness_generation(state: u64) -> Option<u64> {
if state & CORE_READINESS_ACTIVE_BIT != 0 {
Some(state)
} else {
None
}
}
fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64) -> bool {
state
.compare_exchange(
captured_generation,
inactive_core_readiness_state(captured_generation),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
}
#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)]
pub enum RunningMode {
Service,
Sidecar,
@@ -41,42 +73,44 @@ impl fmt::Display for RunningMode {
#[derive(Debug)]
pub struct CoreManager {
/// The Run State this manager reports transitions to.
///
/// A reference rather than the global directly, so a test can supervise a Core without
/// racing every other test for one process-wide Running Mode.
run_state: &'static RunStateStore<RealEnv>,
state: ArcSwap<State>,
last_update: ArcSwapOption<Instant>,
#[cfg(target_os = "windows")]
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
lifecycle_lock: tokio::sync::Mutex<()>,
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
// sidecar→service 交接 watcher 单实例标志。
#[cfg(target_os = "windows")]
handoff_watcher_running: AtomicBool,
}
#[derive(Debug)]
/// Process-level state owned by `CoreManager`.
///
/// Running Mode deliberately does *not* live here — it belongs to `core::runstate`, which
/// keeps it consistent with Service Health and derives PAC availability from it.
#[derive(Debug, Default)]
struct State {
running_mode: ArcSwap<RunningMode>,
child_sidecar: ArcSwapOption<CommandChild>,
}
impl Default for State {
fn default() -> Self {
Self {
running_mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)),
child_sidecar: ArcSwapOption::new(None),
}
}
}
impl Default for CoreManager {
fn default() -> Self {
Self {
run_state: &RUN_STATE,
state: ArcSwap::new(Arc::new(State::default())),
last_update: ArcSwapOption::new(None),
#[cfg(target_os = "windows")]
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
core_readiness_state: AtomicU64::new(0),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
handoff_watcher_running: AtomicBool::new(false),
@@ -89,8 +123,17 @@ impl CoreManager {
Self::default()
}
/// A manager with its own Run State, for tests that must not disturb the process-wide one.
#[cfg(test)]
fn isolated() -> Self {
Self {
run_state: Box::leak(Box::new(RunStateStore::new(RealEnv))),
..Self::default()
}
}
pub fn get_running_mode(&self) -> Arc<RunningMode> {
Arc::clone(&self.state.load().running_mode.load())
self.run_state.mode_arc()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
@@ -101,13 +144,42 @@ impl CoreManager {
.and_then(|arc| Arc::try_unwrap(arc).ok())
}
pub fn get_running_sidecar_pid(&self) -> Option<u32> {
self.state.load().child_sidecar.load_full().map(|child| child.pid())
}
pub fn get_last_update(&self) -> Option<Arc<Instant>> {
self.last_update.load_full()
}
pub fn set_running_mode(&self, mode: RunningMode) {
let state = self.state.load();
state.running_mode.store(Arc::new(mode));
/// The Core is now running in `mode`.
///
/// Run State derives PAC availability and the outward mode mirror from this; callers must
/// not set those alongside.
pub fn core_started(&self, mode: RunningMode) {
self.run_state.core_started(mode);
}
/// The Core is no longer running, for any reason.
///
/// Core readiness is invalidated here rather than inside Run State because readiness
/// belongs to the process this manager supervises.
pub fn core_stopped(&self) {
self.invalidate_core_readiness();
self.run_state.core_stopped();
}
/// A start attempt is under way and the Core is not serving yet.
///
/// Must be paired with [`Self::core_start_settled`] on every path out, including the ones
/// where the start never happened.
pub fn core_starting(&self) {
self.run_state.core_starting();
}
/// The start attempt is over: PAC goes back to following the Running Mode.
pub fn core_start_settled(&self) {
self.run_state.core_start_settled();
}
pub fn set_running_child_sidecar(&self, child: CommandChild) {
@@ -115,6 +187,36 @@ impl CoreManager {
state.child_sidecar.store(Some(Arc::new(child)));
}
fn mark_core_ready(&self) -> u64 {
let mut current = self.core_readiness_state.load(Ordering::Acquire);
loop {
let next = next_active_core_readiness_state(current);
match self
.core_readiness_state
.compare_exchange(current, next, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => return next,
Err(changed) => current = changed,
}
}
}
fn current_core_readiness_generation(&self) -> Option<u64> {
active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire))
}
pub(crate) fn invalidate_core_readiness(&self) {
let _ = self
.core_readiness_state
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
active_core_readiness_generation(current).map(inactive_core_readiness_state)
});
}
fn invalidate_core_readiness_if(&self, captured_generation: u64) -> bool {
claim_core_readiness_generation(&self.core_readiness_state, captured_generation)
}
pub fn set_last_update(&self, time: Instant) {
self.last_update.store(Some(Arc::new(time)));
}
@@ -130,17 +232,63 @@ impl CoreManager {
self.job_handle.store(handle.map(Arc::new));
}
fn try_start_config_update(&self) -> bool {
pub(crate) fn try_start_config_update(&self) -> bool {
!self.config_update_in_progress.swap(true, Ordering::AcqRel)
}
fn finish_config_update(&self) {
pub(crate) fn finish_config_update(&self) {
self.config_update_in_progress.store(false, Ordering::Release);
}
pub async fn init(&self) -> Result<()> {
self.start_core().await?;
Ok(())
pub async fn init(&self) -> Result<bool> {
const MAX_PORT_FALLBACK_RETRIES: usize = 3;
if let Some(reason) = crate::config::Config::startup_core_block_reason() {
anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}");
}
let mut retries = 0;
loop {
match self.start_core().await {
Ok(()) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning));
}
Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => {
if !matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(start_error);
}
match crate::config::Config::retry_startup_mixed_port_fallback().await {
Ok(true) => {
retries += 1;
logging!(
warn,
Type::Core,
"Retrying core startup after mixed proxy port fallback ({}/{})",
retries,
MAX_PORT_FALLBACK_RETRIES
);
}
Ok(false) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(start_error);
}
Err(fallback_error) => {
crate::config::Config::block_startup_core(&fallback_error);
return Err(anyhow::anyhow!(
"core startup failed: {start_error:#}; mixed proxy port fallback failed: \
{fallback_error:#}"
));
}
}
}
Err(error) => {
crate::config::Config::notify_startup_mixed_port_fallback();
return Err(error);
}
}
}
}
}
+228 -37
View File
@@ -1,18 +1,77 @@
#[cfg(test)]
use super::claim_core_readiness_generation;
use super::{CoreManager, RunningMode};
use crate::{
AsyncHandler,
config::{Config, IClashTemp},
core::{handle, logger::Logger, manager::CLASH_LOGGER, service},
config::Config,
core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service},
logging,
utils::dirs,
};
use anyhow::Result;
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use compact_str::CompactString;
use log::Level;
use scopeguard::defer;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
impl CoreManager {
/// A core process is up: put back the node selections the user made.
///
/// mihomo restores these itself, from the `cache.db` it keeps in the directory it was started
/// against — but only when `profile.store-selected` is on and that file survived, and neither
/// is something the app can assume. The setting comes from a merge template a user is free to
/// replace, and a service older than the durable runtime generation hands every start a
/// directory nothing has ever run in. The app holds the same selections in the profile, so it
/// is the one that can say for certain.
///
/// Awaited, and deliberately here rather than beside the proxy: the caller enables the system
/// proxy as soon as the start returns, and pointing it at a core still on the first entry of
/// every group is what this exists to prevent. The wait is bounded — what cannot be put back
/// yet keeps being retried in the background — so a core that will not answer delays a start
/// instead of blocking it.
///
/// Repeat calls supersede each other, so every start path may call this without coordinating.
async fn restore_selected_nodes(&self) {
crate::config::profiles::restore_selected_nodes().await;
}
}
const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400);
async fn poll_sidecar_readiness<F, Fut>(
max_attempts: usize,
retry_delay: std::time::Duration,
mut probe: F,
) -> Result<()>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
let mut last_error = None;
for attempt in 0..max_attempts {
match probe().await {
Ok(()) => return Ok(()),
Err(error) => last_error = Some(error),
}
if attempt + 1 < max_attempts {
tokio::time::sleep(retry_delay).await;
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("sidecar readiness was configured with no attempts"))
.context("Mihomo API did not become ready"))
}
fn should_clear_terminated_sidecar(running_mode: &RunningMode, current_pid: Option<u32>, terminated_pid: u32) -> bool {
matches!(running_mode, RunningMode::Sidecar) && current_pid == Some(terminated_pid)
}
#[cfg(target_os = "windows")]
use {
std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
@@ -39,33 +98,40 @@ impl CoreManager {
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
logging!(info, Type::Core, "Starting core in sidecar mode");
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
let config_dir = dirs::app_home_dir()?;
#[cfg(unix)]
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o007) };
let (mut rx, child) = app_handle
.shell()
.sidecar(clash_core.as_str())?
.args([
"-d",
dirs::path_to_str(&config_dir)?,
"-f",
dirs::path_to_str(&config_file)?,
if cfg!(windows) {
"-ext-ctl-pipe"
} else {
"-ext-ctl-unix"
},
&IClashTemp::guard_external_controller_ipc(),
])
.spawn()?;
let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) };
let command = app_handle.shell().sidecar(clash_core.as_str())?.args([
"-d",
dirs::path_to_str(&config_dir)?,
"-f",
dirs::path_to_str(&config_file)?,
if cfg!(windows) {
"-ext-ctl-pipe"
} else {
"-ext-ctl-unix"
},
dirs::path_to_str(&sidecar_ipc)?,
]);
#[cfg(windows)]
let command = command.env(
"LISTEN_NAMEDPIPE_SDDL",
crate::core::owner_identity::current_user_pipe_sddl()?,
);
let (mut rx, child) = command.spawn()?;
#[cfg(target_os = "windows")]
{
let job = match create_and_assign_sidecar_job(child.pid()) {
let job = {
match create_and_assign_sidecar_job(child.pid()) {
Ok(job) => job,
Err(job_error) => {
let pid = child.pid();
@@ -81,9 +147,8 @@ impl CoreManager {
logging!(error, Type::Core, "Failed to start sidecar: {error:#}");
return Err(error);
}
};
self.set_job_handle(Some(job));
}
}
};
#[cfg(unix)]
unsafe {
@@ -93,10 +158,34 @@ impl CoreManager {
let pid = child.pid();
logging!(trace, Type::Core, "Sidecar started with PID: {}", pid);
self.set_running_child_sidecar(child);
self.set_running_mode(RunningMode::Sidecar);
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
AsyncHandler::spawn(|| async move {
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
@@ -106,6 +195,8 @@ impl CoreManager {
CLASH_LOGGER.append_log(message).await;
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
CompactString::from(format!("Process terminated with code: {}", code))
} else if let Some(signal) = term.signal {
@@ -115,6 +206,7 @@ impl CoreManager {
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs().await;
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
@@ -125,10 +217,12 @@ impl CoreManager {
Ok(())
}
pub(super) fn stop_core_by_sidecar(&self) {
/// Terminates the sidecar after its caller has successfully cleared the
/// system proxy.
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
logging!(info, Type::Core, "Stopping sidecar");
defer! {
self.set_running_mode(RunningMode::NotRunning);
self.core_stopped();
}
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
@@ -159,17 +253,28 @@ impl CoreManager {
pub(super) async fn start_core_by_service(&self) -> Result<()> {
logging!(info, Type::Core, "Starting core in service mode");
self.core_starting();
let service_ipc = dirs::ipc_path()?;
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&service_ipc)?.to_owned())?;
self.start_core_by_service_with_config(&config_file).await
}
pub(super) async fn start_core_by_service_with_config(&self, config_file: &Path) -> Result<()> {
// 交接时等待 sidecar 释放 ext-controller 通道。
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
let mut last_err = None;
for attempt in 0..timing::SERVICE_START_RETRIES {
match service::run_core_by_service(&config_file).await {
match service::run_core_by_service(config_file).await {
Ok(()) => {
self.set_running_mode(RunningMode::Service);
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
return Ok(());
}
Err(e) => {
@@ -191,20 +296,106 @@ impl CoreManager {
#[cfg(not(target_os = "windows"))]
{
service::run_core_by_service(&config_file).await?;
self.set_running_mode(RunningMode::Service);
service::run_core_by_service(config_file).await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
Ok(())
}
}
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
logging!(info, Type::Core, "Stopping service");
defer! {
self.set_running_mode(RunningMode::NotRunning);
}
service::stop_core_by_service().await?;
self.core_stopped();
Ok(())
}
async fn clear_terminated_sidecar(&self, terminated_pid: u32) {
let _life = self.lifecycle_lock.lock().await;
if !should_clear_terminated_sidecar(&self.get_running_mode(), self.get_running_sidecar_pid(), terminated_pid) {
return;
}
let _ = self.take_child_sidecar();
#[cfg(target_os = "windows")]
self.set_job_handle(None);
proxy_control::stop_guard().await;
self.core_stopped();
}
}
#[cfg(test)]
mod readiness_tests {
use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar};
use crate::core::manager::{CoreManager, RunningMode};
use std::{
sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
},
time::Duration,
};
#[tokio::test]
async fn sidecar_readiness_poll_is_bounded_and_accepts_a_real_api_response() -> anyhow::Result<()> {
let attempts = Arc::new(AtomicUsize::new(0));
let probe_attempts = Arc::clone(&attempts);
poll_sidecar_readiness(3, Duration::ZERO, move || {
let attempt = probe_attempts.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if attempt == 3 {
Ok(())
} else {
Err(anyhow::anyhow!("not ready"))
}
}
})
.await?;
assert_eq!(attempts.load(Ordering::SeqCst), 3);
let failed_attempts = Arc::new(AtomicUsize::new(0));
let probe_attempts = Arc::clone(&failed_attempts);
assert!(
poll_sidecar_readiness(3, Duration::ZERO, move || {
probe_attempts.fetch_add(1, Ordering::SeqCst);
async { Err(anyhow::anyhow!("still unavailable")) }
})
.await
.is_err()
);
assert_eq!(failed_attempts.load(Ordering::SeqCst), 3);
Ok(())
}
#[test]
fn only_the_current_sidecar_termination_clears_local_state() {
assert!(should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(42), 42));
assert!(!should_clear_terminated_sidecar(&RunningMode::Sidecar, Some(43), 42));
assert!(!should_clear_terminated_sidecar(&RunningMode::Service, Some(42), 42));
assert!(!should_clear_terminated_sidecar(&RunningMode::NotRunning, None, 42));
}
#[test]
fn core_readiness_generation_can_only_be_claimed_once() {
let generation = AtomicU64::new(7);
assert!(claim_core_readiness_generation(&generation, 7));
assert_eq!(generation.load(Ordering::Acquire), 8);
assert!(!claim_core_readiness_generation(&generation, 7));
}
#[test]
fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() {
let manager = CoreManager::isolated();
manager.mark_core_ready();
manager.core_started(RunningMode::Service);
manager.invalidate_core_readiness();
assert_eq!(*manager.get_running_mode(), RunningMode::Service);
assert_eq!(manager.current_core_readiness_generation(), None);
}
}
#[cfg(target_os = "windows")]
+6
View File
@@ -2,9 +2,15 @@ pub mod autostart;
pub mod backup;
pub mod handle;
pub mod hotkey;
pub mod listener;
pub mod logger;
pub mod manager;
mod notification;
pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
pub mod runstate;
mod runtime_bundle;
pub mod service;
pub mod sysopt;
pub mod timer;
+2
View File
@@ -14,6 +14,7 @@ pub enum FrontendEvent<'a> {
TimerUpdated { profile_index: &'a String },
ProfileUpdateStarted { uid: &'a String },
ProfileUpdateCompleted { uid: &'a String },
RunStateChanged { state: serde_json::Value },
}
#[derive(Debug)]
@@ -38,6 +39,7 @@ impl NotificationSystem {
FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))),
FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))),
FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))),
FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)),
}
}
+575
View File
@@ -0,0 +1,575 @@
use crate::utils::dirs;
use anyhow::{Context as _, Result};
use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity};
use std::path::Path;
pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> {
current_owner_credentials_for_root(&dirs::app_home_dir()?)
}
#[allow(clippy::unnecessary_wraps)] // Windows SID discovery is fallible; Unix keeps the shared API.
pub(crate) fn current_owner_identity() -> Result<OwnerIdentity> {
#[cfg(unix)]
return Ok(OwnerIdentity::Unix {
uid: unsafe { libc::geteuid() },
gid: unsafe { libc::getegid() },
});
#[cfg(windows)]
{
windows_current_identity()
}
}
pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result<OwnerCredentials> {
let app_data_root = std::fs::canonicalize(app_root)
.with_context(|| format!("failed to canonicalize application data root {app_root:?}"))?;
#[cfg(unix)]
let (identity, token) = (current_owner_identity()?, None);
#[cfg(windows)]
let (identity, token) = windows_owner_credentials(&app_data_root)?;
Ok(OwnerCredentials {
identity,
app_data_dir: app_data_root.to_string_lossy().into_owned(),
token,
})
}
#[cfg(windows)]
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<String>)> {
let sid = windows_owner::current_sid()?;
let token = windows_owner::load_or_create_token(app_data_root, &sid)?;
Ok((OwnerIdentity::Windows { sid }, Some(token)))
}
#[cfg(windows)]
fn windows_current_identity() -> Result<OwnerIdentity> {
Ok(OwnerIdentity::Windows {
sid: windows_owner::current_sid()?,
})
}
#[cfg(windows)]
pub(crate) fn create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::create_private_file(path)
}
#[cfg(windows)]
pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::open_private_file(path)
}
#[cfg(windows)]
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
windows_owner::open_or_create_private_file(path)
}
#[cfg(windows)]
pub(crate) fn current_user_pipe_sddl() -> Result<String> {
let sid = windows_owner::current_sid()?;
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)"))
}
#[cfg(windows)]
mod windows_owner {
use anyhow::{Context as _, Result, bail};
use clash_verge_service_ipc::OWNER_TOKEN_FILE_NAME;
use std::ffi::c_void;
use std::io::{Read as _, Write as _};
use std::os::windows::ffi::OsStrExt as _;
use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
use std::path::Path;
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_FILE_EXISTS, GENERIC_READ, GENERIC_WRITE, GetLastError, INVALID_HANDLE_VALUE, LocalFree,
};
use windows_sys::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
SE_FILE_OBJECT, SetSecurityInfo,
};
use windows_sys::Win32::Security::{
ACCESS_ALLOWED_ACE, ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetSecurityDescriptorControl,
GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, IsValidSid, IsWellKnownSid,
OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, GetFileInformationByHandle, GetFileType,
OPEN_EXISTING, READ_CONTROL, WRITE_DAC,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
const TOKEN_BYTES: usize = 32;
const ACCESS_ALLOWED_ACE_TYPE: u8 = 0;
pub(super) fn current_sid() -> Result<String> {
let mut token = std::ptr::null_mut();
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return Err(std::io::Error::last_os_error()).context("failed to open process token");
}
let token = OwnedHandle(token);
let mut required = 0_u32;
unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut required) };
if required == 0 {
return Err(std::io::Error::last_os_error()).context("failed to size process SID buffer");
}
let words = (required as usize).div_ceil(std::mem::size_of::<usize>());
let mut buffer = vec![0_usize; words];
if unsafe { GetTokenInformation(token.0, TokenUser, buffer.as_mut_ptr().cast(), required, &mut required) } == 0
{
return Err(std::io::Error::last_os_error()).context("failed to read process SID");
}
let token_user = unsafe { &*buffer.as_ptr().cast::<TOKEN_USER>() };
sid_to_string(token_user.User.Sid)
}
pub(super) fn load_or_create_token(app_data_root: &Path, sid: &str) -> Result<String> {
let token_path = app_data_root.join(OWNER_TOKEN_FILE_NAME);
let descriptor = LocalSecurityDescriptor::from_sid(sid)?;
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let wide = wide_path(&token_path)?;
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
&attributes,
CREATE_NEW,
FILE_ATTRIBUTE_HIDDEN | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle != INVALID_HANDLE_VALUE {
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
let mut token = [0_u8; TOKEN_BYTES];
getrandom::fill(&mut token).context("failed to generate owner token")?;
file.write_all(&token).context("failed to write owner token")?;
file.sync_all().context("failed to flush owner token")?;
return Ok(encode_token(&token));
}
if unsafe { GetLastError() } != ERROR_FILE_EXISTS {
return Err(std::io::Error::last_os_error()).context("failed to create owner token");
}
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | READ_CONTROL | WRITE_DAC,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error()).context("failed to open owner token");
}
let mut file = unsafe { std::fs::File::from_raw_handle(handle) };
validate_token_file(&file, descriptor.owner()?)?;
descriptor.apply_dacl(file.as_raw_handle())?;
let mut token = [0_u8; TOKEN_BYTES];
file.read_exact(&mut token).context("failed to read owner token")?;
Ok(encode_token(&token))
}
pub(super) fn create_private_file(path: &Path) -> Result<std::fs::File> {
let sid = current_sid()?;
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
create_file(path, &descriptor)
}
pub(super) fn open_private_file(path: &Path) -> Result<std::fs::File> {
let sid = current_sid()?;
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
let file = open_file(path)?;
validate_private_file(&file, descriptor.owner()?)?;
Ok(file)
}
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
let sid = current_sid()?;
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
match create_file(path, &descriptor) {
Ok(file) => Ok(file),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
{
let file = open_file(path)?;
validate_private_file(&file, descriptor.owner()?)?;
Ok(file)
}
Err(error) => Err(error),
}
}
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
let attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: descriptor.0,
bInheritHandle: 0,
};
let wide = wide_path(path)?;
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
&attributes,
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error()).context("failed to create private current-user file");
}
let file = unsafe { std::fs::File::from_raw_handle(handle) };
validate_private_file(&file, descriptor.owner()?)?;
Ok(file)
}
fn open_file(path: &Path) -> Result<std::fs::File> {
let wide = wide_path(path)?;
let handle = unsafe {
CreateFileW(
wide.as_ptr(),
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error()).context("failed to open private current-user file");
}
Ok(unsafe { std::fs::File::from_raw_handle(handle) })
}
fn validate_private_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
let handle = file.as_raw_handle();
let mut information = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
return Err(std::io::Error::last_os_error()).context("failed to inspect private file metadata");
}
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
{
bail!("current-user file is not an ordinary file");
}
let mut owner = std::ptr::null_mut();
let mut dacl: *mut ACL = std::ptr::null_mut();
let mut security = std::ptr::null_mut();
let status = unsafe {
GetSecurityInfo(
handle,
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
&mut dacl,
std::ptr::null_mut(),
&mut security,
)
};
if status != 0 || security.is_null() {
bail!("failed to inspect private file security: Windows error {status}");
}
let security = LocalSecurityDescriptor(security);
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 || dacl.is_null() {
bail!("current-user file owner or DACL is invalid");
}
let mut control = 0_u16;
let mut revision = 0_u32;
if unsafe { GetSecurityDescriptorControl(security.0, &mut control, &mut revision) } == 0
|| control & SE_DACL_PROTECTED == 0
{
bail!("current-user file DACL is not protected");
}
let mut owner_ace = false;
let mut system_ace = false;
let mut administrators_ace = false;
for index in 0..unsafe { (*dacl).AceCount } as u32 {
let mut ace = std::ptr::null_mut();
if unsafe { GetAce(dacl, index, &mut ace) } == 0 || ace.is_null() {
bail!("current-user file DACL could not be inspected");
}
let allowed = unsafe { &*ace.cast::<ACCESS_ALLOWED_ACE>() };
if allowed.Header.AceType != ACCESS_ALLOWED_ACE_TYPE || allowed.Mask & FILE_ALL_ACCESS != FILE_ALL_ACCESS {
bail!("current-user file DACL contains an unexpected ACE");
}
let ace_sid = std::ptr::addr_of!(allowed.SidStart).cast_mut().cast::<c_void>();
if unsafe { IsValidSid(ace_sid) } == 0 {
bail!("current-user file DACL contains an invalid SID");
}
if unsafe { EqualSid(ace_sid, expected_owner) } != 0 {
owner_ace = true;
} else if unsafe { IsWellKnownSid(ace_sid, WinLocalSystemSid) } != 0 {
system_ace = true;
} else if unsafe { IsWellKnownSid(ace_sid, WinBuiltinAdministratorsSid) } != 0 {
administrators_ace = true;
} else {
bail!("current-user file DACL grants access to another SID");
}
}
if unsafe { (*dacl).AceCount } != 3 || !owner_ace || !system_ace || !administrators_ace {
bail!("current-user file DACL is missing a required principal");
}
Ok(())
}
fn validate_token_file(file: &std::fs::File, expected_owner: PSID) -> Result<()> {
let handle = file.as_raw_handle();
let mut information = BY_HANDLE_FILE_INFORMATION::default();
if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
return Err(std::io::Error::last_os_error()).context("failed to inspect owner token metadata");
}
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|| unsafe { GetFileType(handle) } != FILE_TYPE_DISK
|| information.nFileSizeHigh != 0
|| information.nFileSizeLow != TOKEN_BYTES as u32
{
bail!("owner token is not an ordinary 32-byte file");
}
let mut owner = std::ptr::null_mut();
let mut security = std::ptr::null_mut();
let status = unsafe {
GetSecurityInfo(
handle,
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut security,
)
};
if status != 0 || security.is_null() {
bail!("failed to inspect owner token security: Windows error {status}");
}
let security = LocalSecurityDescriptor(security);
if owner.is_null() || unsafe { EqualSid(owner, expected_owner) } == 0 {
bail!("owner token belongs to a different Windows user");
}
drop(security);
Ok(())
}
fn sid_to_string(sid: PSID) -> Result<String> {
let mut value = std::ptr::null_mut();
if unsafe { ConvertSidToStringSidW(sid, &mut value) } == 0 || value.is_null() {
return Err(std::io::Error::last_os_error()).context("failed to format process SID");
}
let value = LocalWideString(value);
let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count();
String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
.context("process SID is not valid UTF-16")
}
fn encode_token(token: &[u8; TOKEN_BYTES]) -> String {
let mut encoded = String::with_capacity(TOKEN_BYTES * 2);
for byte in token {
use std::fmt::Write as _;
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
fn wide_path(path: &Path) -> Result<Vec<u16>> {
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if wide.contains(&0) {
bail!("owner token path contains NUL");
}
wide.push(0);
Ok(wide)
}
struct OwnedHandle(*mut c_void);
impl Drop for OwnedHandle {
fn drop(&mut self) {
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
unsafe { CloseHandle(self.0) };
}
}
}
struct LocalWideString(*mut u16);
impl Drop for LocalWideString {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { LocalFree(self.0.cast()) };
}
}
}
struct LocalSecurityDescriptor(PSECURITY_DESCRIPTOR);
impl LocalSecurityDescriptor {
fn from_sid(sid: &str) -> Result<Self> {
let sddl = format!("O:{sid}D:P(A;;FA;;;{sid})(A;;FA;;;SY)(A;;FA;;;BA)");
let mut wide: Vec<u16> = sddl.encode_utf16().collect();
wide.push(0);
let mut descriptor = std::ptr::null_mut();
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
wide.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
std::ptr::null_mut(),
)
} == 0
|| descriptor.is_null()
{
return Err(std::io::Error::last_os_error()).context("failed to build owner token security descriptor");
}
Ok(Self(descriptor))
}
fn owner(&self) -> Result<PSID> {
let mut owner = std::ptr::null_mut();
let mut defaulted = 0;
if unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } == 0 || owner.is_null() {
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor owner");
}
Ok(owner)
}
fn apply_dacl(&self, handle: *mut c_void) -> Result<()> {
let mut present = 0;
let mut defaulted = 0;
let mut dacl = std::ptr::null_mut();
if unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } == 0
|| present == 0
|| dacl.is_null()
{
return Err(std::io::Error::last_os_error()).context("failed to read token descriptor DACL");
}
let status = unsafe {
SetSecurityInfo(
handle,
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
std::ptr::null_mut(),
std::ptr::null_mut(),
dacl,
std::ptr::null(),
)
};
if status != 0 {
bail!("failed to restrict owner token DACL: Windows error {status}");
}
Ok(())
}
}
impl Drop for LocalSecurityDescriptor {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { LocalFree(self.0) };
}
}
}
}
#[cfg(test)]
mod tests {
use super::current_owner_credentials_for_root;
use clash_verge_service_ipc::OwnerIdentity;
#[cfg(unix)]
#[test]
fn unix_credentials_come_from_process_and_have_no_token() -> anyhow::Result<()> {
let app_root = std::env::temp_dir();
let credentials = current_owner_credentials_for_root(&app_root)?;
assert_eq!(
credentials.identity,
OwnerIdentity::Unix {
uid: unsafe { libc::geteuid() },
gid: unsafe { libc::getegid() },
}
);
assert_eq!(credentials.token, None);
assert_eq!(
std::path::PathBuf::from(credentials.app_data_dir),
std::fs::canonicalize(app_root)?
);
Ok(())
}
#[cfg(windows)]
#[test]
fn windows_credentials_use_stable_sid_and_private_token() -> anyhow::Result<()> {
let app_root = std::env::temp_dir().join(format!("cvr-owner-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&app_root);
std::fs::create_dir(&app_root)?;
let first = current_owner_credentials_for_root(&app_root)?;
let second = current_owner_credentials_for_root(&app_root)?;
let OwnerIdentity::Windows { sid } = &first.identity else {
anyhow::bail!("expected Windows owner identity");
};
assert!(sid.starts_with("S-1-5-"));
assert_eq!(first.identity, second.identity);
assert_eq!(first.token, second.token);
assert_eq!(first.token.as_deref().map(str::len), Some(64));
std::fs::remove_dir_all(app_root)?;
Ok(())
}
#[cfg(windows)]
#[test]
fn private_file_open_rejects_directories_and_reparse_points() -> anyhow::Result<()> {
use super::{create_private_current_user_file, open_private_current_user_file};
let root = std::env::temp_dir().join(format!("cvr-private-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir(&root)?;
let private_file = root.join("record.json");
drop(create_private_current_user_file(&private_file)?);
drop(open_private_current_user_file(&private_file)?);
let directory = root.join("directory");
std::fs::create_dir(&directory)?;
assert!(open_private_current_user_file(&directory).is_err());
let link = root.join("record-link.json");
if let Err(error) = std::os::windows::fs::symlink_file(&private_file, &link) {
if error.raw_os_error() == Some(1314) {
std::fs::remove_dir_all(root)?;
return Ok(());
}
return Err(error.into());
}
assert!(open_private_current_user_file(&link).is_err());
std::fs::remove_file(link)?;
std::fs::remove_dir_all(root)?;
Ok(())
}
}
+501
View File
@@ -0,0 +1,501 @@
use crate::{
config::{Config, IVerge, MixedPort},
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
process::AsyncHandler,
utils::server,
};
use anyhow::{Result, bail, ensure};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
use std::{
future::Future,
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use tokio::sync::Mutex;
const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
struct ServiceProxyOperations {
guard_generation: AtomicU64,
operation_lock: Mutex<()>,
}
impl ServiceProxyOperations {
const fn new() -> Self {
Self {
guard_generation: AtomicU64::new(0),
operation_lock: Mutex::const_new(()),
}
}
fn invalidate_guard(&self) -> u64 {
self.guard_generation.fetch_add(1, Ordering::AcqRel).wrapping_add(1)
}
async fn run_service_operation<F, Fut, T>(&self, operation: F) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
let _operation = self.operation_lock.lock().await;
operation().await
}
async fn run_final_service_operation<F, Fut, T>(&self, operation: F) -> T
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
self.invalidate_guard();
self.run_service_operation(operation).await
}
async fn cancel_and_drain<F, Fut>(&self, cancel: F) -> u64
where
F: FnOnce() -> Fut,
Fut: Future<Output = ()>,
{
let generation = self.invalidate_guard();
self.run_service_operation(cancel).await;
generation
}
async fn run_guard_request<Mode, ActiveProof, Request, RequestFuture>(
&self,
captured_generation: u64,
captured_proof: &OwnerSessionProof,
is_service_mode: Mode,
active_proof: ActiveProof,
request: Request,
) -> Result<bool>
where
Mode: FnOnce() -> bool,
ActiveProof: FnOnce() -> Result<OwnerSessionProof>,
Request: FnOnce() -> RequestFuture,
RequestFuture: Future<Output = Result<ProxyApplyOutcome>>,
{
self.run_service_operation(|| async move {
if !guard_generation_is_current(&self.guard_generation, captured_generation)
|| !is_service_mode()
|| !active_proof().is_ok_and(|active| active == *captured_proof)
{
return Ok(false);
}
service_apply_result(request().await?)?;
Ok(true)
})
.await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProxyBackendRoute {
Local,
Service,
}
const fn proxy_backend_route(is_macos: bool, running_mode: &RunningMode) -> ProxyBackendRoute {
if is_macos && matches!(running_mode, RunningMode::Service) {
ProxyBackendRoute::Service
} else {
ProxyBackendRoute::Local
}
}
fn guard_generation_is_current(generation: &AtomicU64, captured_generation: u64) -> bool {
generation.load(Ordering::Acquire) == captured_generation
}
fn truncate_utf8(value: &mut String, max_bytes: usize) {
if value.len() <= max_bytes {
return;
}
let mut boundary = max_bytes;
while !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
}
fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
let mut bypass = if custom.is_empty() {
MACOS_DEFAULT_BYPASS.to_owned()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}")
} else {
custom.to_owned()
};
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass)
}
fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Result<MacosProxyConfig> {
if !verge.enable_system_proxy.unwrap_or_default() {
return Ok(MacosProxyConfig::Disabled);
}
if verge.proxy_auto_config.unwrap_or_default() {
ensure!(pac_port != 0, "embedded PAC server port must not be zero");
Ok(MacosProxyConfig::Pac {
url: format!("http://{LOOPBACK_HOST}:{pac_port}/commands/pac"),
})
} else {
ensure!(mixed_port != 0, "system proxy port must not be zero");
Ok(MacosProxyConfig::Global {
host: LOOPBACK_HOST.to_owned(),
port: mixed_port,
bypass: service_bypass(verge)?,
})
}
}
fn service_apply_result(outcome: ProxyApplyOutcome) -> Result<()> {
match outcome {
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
ProxyApplyOutcome::DirectFallback { message } => bail!(message),
}
}
async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig> {
if !verge.enable_system_proxy.unwrap_or_default() {
return Ok(MacosProxyConfig::Disabled);
}
if verge.proxy_auto_config.unwrap_or_default() {
return service_proxy_config(verge, 0, server::embedded_server_port()?);
}
// Configured: this builds the proxy settings handed to the service alongside a start.
let mixed_port = MixedPort::desired().await;
service_proxy_config(verge, mixed_port, 0)
}
pub async fn apply() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
ProxyBackendRoute::Local => Sysopt::global().update_sysproxy().await,
ProxyBackendRoute::Service => {
let verge = Config::verge().await.latest_arc();
let proxy = current_service_proxy_config(&verge).await?;
SERVICE_PROXY_OPERATIONS
.run_final_service_operation(|| async {
service_apply_result(service::set_system_proxy_by_service(&proxy).await?)
})
.await
}
}
}
pub async fn clear() -> Result<()> {
let running_mode = CoreManager::global().get_running_mode();
match proxy_backend_route(cfg!(target_os = "macos"), &running_mode) {
ProxyBackendRoute::Local => Sysopt::global().reset_sysproxy().await,
ProxyBackendRoute::Service => {
SERVICE_PROXY_OPERATIONS
.run_final_service_operation(|| async {
service_apply_result(service::set_system_proxy_by_service(&MacosProxyConfig::Disabled).await?)
})
.await
}
}
}
pub async fn refresh_guard() -> Result<()> {
let generation = SERVICE_PROXY_OPERATIONS
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
.await;
let running_mode = CoreManager::global().get_running_mode();
if matches!(
proxy_backend_route(cfg!(target_os = "macos"), &running_mode),
ProxyBackendRoute::Local
) {
Sysopt::global().refresh_guard().await;
return Ok(());
}
let verge = Config::verge().await.latest_arc();
if !verge.enable_system_proxy.unwrap_or_default() || !verge.enable_proxy_guard.unwrap_or_default() {
return Ok(());
}
let proxy = current_service_proxy_config(&verge).await?;
let proof = service::active_service_session()?;
let interval = Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30).max(1));
AsyncHandler::spawn(move || async move {
loop {
tokio::time::sleep(interval).await;
match SERVICE_PROXY_OPERATIONS
.run_guard_request(
generation,
&proof,
|| matches!(*CoreManager::global().get_running_mode(), RunningMode::Service),
service::active_service_session,
|| service::set_system_proxy_by_service_with_session(&proxy, &proof),
)
.await
{
Ok(true) => {}
Ok(false) => break,
Err(_) => logging!(warn, Type::Core, "failed to refresh system proxy through Service"),
}
}
});
Ok(())
}
pub async fn stop_guard() {
SERVICE_PROXY_OPERATIONS
.cancel_and_drain(|| Sysopt::global().stop_proxy_guard())
.await;
}
#[cfg(test)]
mod tests {
use super::{
ProxyBackendRoute, ServiceProxyOperations, guard_generation_is_current, proxy_backend_route,
service_proxy_config,
};
use crate::{config::IVerge, core::manager::RunningMode};
use clash_verge_service_ipc::{MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome};
use parking_lot::Mutex;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::task::Poll;
use tokio::sync::Barrier;
fn proof(generation: u64, token: &str) -> OwnerSessionProof {
OwnerSessionProof {
generation,
token: token.to_owned(),
}
}
#[test]
fn only_macos_service_routes_proxy_to_helper() {
assert_eq!(
proxy_backend_route(true, &RunningMode::Service),
ProxyBackendRoute::Service
);
assert_eq!(
proxy_backend_route(true, &RunningMode::Sidecar),
ProxyBackendRoute::Local
);
assert_eq!(
proxy_backend_route(false, &RunningMode::Service),
ProxyBackendRoute::Local
);
}
#[test]
fn stale_periodic_refresh_stops_after_owner_loss() {
let generation = AtomicU64::new(7);
let captured_generation = generation.load(Ordering::Acquire);
assert!(guard_generation_is_current(&generation, captured_generation));
generation.fetch_add(1, Ordering::AcqRel);
assert!(!guard_generation_is_current(&generation, captured_generation));
}
#[tokio::test]
async fn final_service_mutation_finishes_after_in_flight_guard_request() {
let operations = Arc::new(ServiceProxyOperations::new());
let generation = operations.invalidate_guard();
let captured_proof = proof(1, "captured");
let active_proof = Arc::new(Mutex::new(captured_proof.clone()));
let guard_started = Arc::new(Barrier::new(2));
let release_guard = Arc::new(Barrier::new(2));
let events = Arc::new(Mutex::new(Vec::new()));
let guard_task = {
let operations = Arc::clone(&operations);
let captured_proof = captured_proof.clone();
let active_proof = Arc::clone(&active_proof);
let guard_started = Arc::clone(&guard_started);
let release_guard = Arc::clone(&release_guard);
let events = Arc::clone(&events);
tokio::spawn(async move {
operations
.run_guard_request(
generation,
&captured_proof,
|| true,
|| Ok(active_proof.lock().clone()),
|| async {
events.lock().push("guard-started");
guard_started.wait().await;
release_guard.wait().await;
events.lock().push("guard-finished");
Ok(ProxyApplyOutcome::Applied)
},
)
.await
})
};
guard_started.wait().await;
let mut final_operation = Box::pin(operations.run_final_service_operation(|| async {
events.lock().push("final");
Ok::<_, anyhow::Error>(())
}));
assert!(matches!(futures::poll!(final_operation.as_mut()), Poll::Pending));
assert!(!guard_generation_is_current(&operations.guard_generation, generation));
release_guard.wait().await;
assert!(matches!(guard_task.await, Ok(Ok(true))));
assert!(matches!(final_operation.await, Ok(())));
assert_eq!(&*events.lock(), &["guard-started", "guard-finished", "final"]);
}
#[tokio::test]
async fn generation_invalidation_while_guard_waits_prevents_rpc() {
let operations = Arc::new(ServiceProxyOperations::new());
let generation = operations.invalidate_guard();
let captured_proof = proof(1, "captured");
let blocker_started = Arc::new(Barrier::new(2));
let release_blocker = Arc::new(Barrier::new(2));
let blocker = {
let operations = Arc::clone(&operations);
let blocker_started = Arc::clone(&blocker_started);
let release_blocker = Arc::clone(&release_blocker);
tokio::spawn(async move {
operations
.run_service_operation(|| async {
blocker_started.wait().await;
release_blocker.wait().await;
})
.await
})
};
blocker_started.wait().await;
let rpc_called = AtomicBool::new(false);
let mut guard_request = Box::pin(operations.run_guard_request(
generation,
&captured_proof,
|| true,
|| Ok(captured_proof.clone()),
|| async {
rpc_called.store(true, Ordering::Release);
Ok(ProxyApplyOutcome::Applied)
},
));
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
operations.invalidate_guard();
release_blocker.wait().await;
assert!(blocker.await.is_ok());
assert!(matches!(guard_request.await, Ok(false)));
assert!(!rpc_called.load(Ordering::Acquire));
}
#[tokio::test]
async fn old_guard_loop_does_not_adopt_replacement_proof() {
let operations = Arc::new(ServiceProxyOperations::new());
let generation = operations.invalidate_guard();
let captured_proof = proof(1, "captured");
let replacement_proof = proof(2, "replacement");
let active_proof = Mutex::new(captured_proof.clone());
let rpc_called = AtomicBool::new(false);
let blocker_started = Arc::new(Barrier::new(2));
let release_blocker = Arc::new(Barrier::new(2));
let blocker = {
let operations = Arc::clone(&operations);
let blocker_started = Arc::clone(&blocker_started);
let release_blocker = Arc::clone(&release_blocker);
tokio::spawn(async move {
operations
.run_service_operation(|| async {
blocker_started.wait().await;
release_blocker.wait().await;
})
.await
})
};
blocker_started.wait().await;
let mut guard_request = Box::pin(operations.run_guard_request(
generation,
&captured_proof,
|| true,
|| Ok(active_proof.lock().clone()),
|| async {
rpc_called.store(true, Ordering::Release);
Ok(ProxyApplyOutcome::Applied)
},
));
assert!(matches!(futures::poll!(guard_request.as_mut()), Poll::Pending));
*active_proof.lock() = replacement_proof;
release_blocker.wait().await;
assert!(blocker.await.is_ok());
assert!(matches!(guard_request.await, Ok(false)));
assert!(!rpc_called.load(Ordering::Acquire));
}
#[tokio::test]
async fn guard_refresh_rejects_direct_fallback() {
let operations = ServiceProxyOperations::new();
let generation = operations.invalidate_guard();
let captured_proof = proof(1, "captured");
let refreshed = operations
.run_guard_request(
generation,
&captured_proof,
|| true,
|| Ok(captured_proof.clone()),
|| async {
Ok(ProxyApplyOutcome::DirectFallback {
message: "fallback detail".to_owned(),
})
},
)
.await;
assert!(refreshed.is_err());
}
#[test]
fn service_proxy_config_forces_loopback_targets_and_bounds_bypass() {
let verge = IVerge {
enable_system_proxy: Some(true),
proxy_auto_config: Some(false),
proxy_host: Some("192.0.2.1".into()),
system_proxy_bypass: Some(format!("{}界", "x".repeat(8191)).into()),
use_default_bypass: Some(false),
..IVerge::default()
};
let proxy = service_proxy_config(&verge, 7897, 3333).unwrap_or_else(|_| unreachable!());
let MacosProxyConfig::Global { host, port, bypass } = proxy else {
unreachable!();
};
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 7897);
assert!(bypass.len() <= 8192);
assert!(bypass.is_char_boundary(bypass.len()));
let pac_verge = IVerge {
proxy_auto_config: Some(true),
..verge
};
assert_eq!(
service_proxy_config(&pac_verge, 7897, 3333).unwrap_or_else(|_| unreachable!()),
MacosProxyConfig::Pac {
url: "http://127.0.0.1:3333/commands/pac".to_owned()
}
);
}
}
+783
View File
@@ -0,0 +1,783 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
};
pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>,
pub proxies: Proxies,
pub providers: Option<ProxyProviders>,
}
pub struct ProxyViewBuilder;
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyViewV1 {
pub schema_version: u8,
pub order_source: ProxyViewOrderSource,
pub provider_state: ProxyViewProviderState,
pub global: Option<ProxyGroupView>,
pub direct: Option<String>,
pub groups: Vec<ProxyGroupView>,
pub records: BTreeMap<String, ProxyNodeView>,
pub standalone: Vec<String>,
pub providers: Vec<ProxyProviderView>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyViewOrderSource {
Runtime,
Fallback,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ProxyViewProviderState {
Ready,
Unavailable,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct ProxyCapabilities {
pub udp: bool,
pub xudp: bool,
pub tfo: bool,
pub mptcp: bool,
pub smux: bool,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyGroupView {
pub name: String,
#[serde(rename = "type")]
pub proxy_type: ProxyType,
pub alive: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub now: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
pub history: Vec<DelayHistory>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyNodeView {
pub record_id: String,
pub name: String,
#[serde(rename = "type")]
pub proxy_type: ProxyType,
pub alive: bool,
pub history: Vec<DelayHistory>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_url: Option<String>,
#[serde(flatten)]
pub capabilities: ProxyCapabilities,
pub source: ProxyNodeSource,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyNodeSource {
Core {
#[serde(rename = "proxyName")]
proxy_name: String,
},
Provider {
#[serde(rename = "providerName")]
provider_name: String,
#[serde(rename = "proxyName")]
proxy_name: String,
},
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProxyMemberRef {
Group {
name: String,
},
Node {
name: String,
#[serde(rename = "recordId")]
record_id: String,
},
Unresolved {
name: String,
reason: ProxyMemberUnresolvedReason,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum ProxyMemberUnresolvedReason {
#[serde(rename = "missing")]
Missing,
#[serde(rename = "ambiguous")]
Ambiguous,
#[serde(rename = "provider-unavailable")]
ProviderUnavailable,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxyProviderView {
pub name: String,
pub vehicle_type: ProxyProviderVehicleType,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription_info: Option<ProxySubscriptionInfo>,
pub proxy_record_ids: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum ProxyProviderVehicleType {
#[serde(rename = "HTTP")]
Http,
#[serde(rename = "File")]
File,
#[serde(rename = "Inline")]
Inline,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct ProxySubscriptionInfo {
pub upload: i64,
pub download: i64,
pub total: i64,
pub expire: i64,
}
struct MemberResolver<'a> {
group_names: BTreeSet<String>,
core_node_ids: &'a BTreeMap<String, String>,
provider_candidates: &'a BTreeMap<String, Vec<String>>,
provider_available: bool,
}
impl MemberResolver<'_> {
fn resolve(&self, name: String) -> ProxyMemberRef {
if self.group_names.contains(&name) {
ProxyMemberRef::Group { name }
} else if let Some(record_id) = self.core_node_ids.get(&name) {
ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
}
} else if !self.provider_available {
ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
}
} else {
match self.provider_candidates.get(&name).map(Vec::as_slice) {
Some([record_id]) => ProxyMemberRef::Node {
name,
record_id: record_id.clone(),
},
None => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Missing,
},
Some(_) => ProxyMemberRef::Unresolved {
name,
reason: ProxyMemberUnresolvedReason::Ambiguous,
},
}
}
}
}
impl ProxyViewBuilder {
pub fn build(input: ProxyViewInput) -> ProxyViewV1 {
let ProxyViewInput {
runtime_group_order,
proxies,
providers,
} = input;
let provider_state = if providers.is_some() {
ProxyViewProviderState::Ready
} else {
ProxyViewProviderState::Unavailable
};
let (mut core_groups, core_nodes) = partition_core(proxies);
let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver {
group_names: core_groups.keys().cloned().collect(),
core_node_ids: &core_node_ids,
provider_candidates: &provider_candidates,
provider_available: provider_state == ProxyViewProviderState::Ready,
};
let global = core_groups
.remove("GLOBAL")
.map(|proxy| build_group("GLOBAL".to_owned(), proxy, &resolver));
let (groups, order_source) = build_ordered_groups(core_groups, &runtime_group_order, &resolver);
let direct = core_node_ids.get("DIRECT").cloned();
let standalone = build_standalone(&core_node_ids);
ProxyViewV1 {
schema_version: 1,
order_source,
provider_state,
global,
direct,
groups,
records,
standalone,
providers,
}
}
}
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies {
if proxy.all.is_some() {
groups.insert(name, proxy);
} else {
nodes.insert(name, proxy);
}
}
(groups, nodes)
}
fn build_core_records(
core_nodes: BTreeMap<String, Proxy>,
) -> (BTreeMap<String, ProxyNodeView>, BTreeMap<String, String>) {
let mut records = BTreeMap::new();
let mut ids = BTreeMap::new();
for (index, (name, proxy)) in core_nodes.into_iter().enumerate() {
let record_id = format!("c:{index}");
ids.insert(name.clone(), record_id.clone());
records.insert(
record_id.clone(),
build_node(
record_id,
name.clone(),
proxy,
ProxyNodeSource::Core { proxy_name: name },
),
);
}
(records, ids)
}
fn build_provider_records(
providers: Option<ProxyProviders>,
records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, BTreeMap<String, Vec<String>>) {
let providers = providers
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
.unwrap_or_default();
let mut views = Vec::new();
let mut candidates = BTreeMap::new();
for (provider_name, provider) in providers {
let ProxyProvider {
vehicle_type,
proxies,
updated_at,
subscription_info,
..
} = provider;
let vehicle_type = match vehicle_type {
VehicleType::HTTP => ProxyProviderVehicleType::Http,
VehicleType::File => ProxyProviderVehicleType::File,
VehicleType::Inline => ProxyProviderVehicleType::Inline,
_ => continue,
};
let provider_index = views.len();
let mut proxy_record_ids = Vec::new();
for (member_index, proxy) in proxies.into_iter().enumerate() {
let record_id = format!("p:{provider_index}:{member_index}");
let proxy_name = proxy.name.clone();
let node_name = proxy.name.clone();
candidates
.entry(proxy_name.clone())
.or_insert_with(Vec::new)
.push(record_id.clone());
records.insert(
record_id.clone(),
build_node(
record_id.clone(),
node_name,
proxy,
ProxyNodeSource::Provider {
provider_name: provider_name.clone(),
proxy_name,
},
),
);
proxy_record_ids.push(record_id);
}
views.push(ProxyProviderView {
name: provider_name,
vehicle_type,
updated_at,
subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo {
upload: subscription_info.upload,
download: subscription_info.download,
total: subscription_info.total,
expire: subscription_info.expire,
}),
proxy_record_ids,
});
}
(views, candidates)
}
fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> ProxyGroupView {
let Proxy {
all,
fixed,
hidden,
icon,
now,
test_url,
alive,
history,
udp,
xudp,
tfo,
mptcp,
smux,
proxy_type,
..
} = proxy;
ProxyGroupView {
name,
proxy_type,
alive,
now,
fixed,
hidden,
icon,
test_url,
history,
capabilities: ProxyCapabilities {
udp,
xudp,
tfo,
mptcp,
smux,
},
members: all
.unwrap_or_default()
.into_iter()
.map(|name| resolver.resolve(name))
.collect(),
}
}
fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSource) -> ProxyNodeView {
let Proxy {
id,
hidden,
icon,
test_url,
alive,
history,
udp,
xudp,
tfo,
mptcp,
smux,
proxy_type,
..
} = proxy;
ProxyNodeView {
record_id,
name,
proxy_type,
alive,
history,
id,
hidden,
icon,
test_url,
capabilities: ProxyCapabilities {
udp,
xudp,
tfo,
mptcp,
smux,
},
source,
}
}
fn build_ordered_groups(
mut core_groups: BTreeMap<String, Proxy>,
runtime_group_order: &[String],
resolver: &MemberResolver<'_>,
) -> (Vec<ProxyGroupView>, ProxyViewOrderSource) {
let mut selected = BTreeSet::new();
let mut names = Vec::new();
for name in runtime_group_order {
if core_groups.contains_key(name) && selected.insert(name.clone()) {
names.push(name.clone());
}
}
let order_source = if names.is_empty() {
ProxyViewOrderSource::Fallback
} else {
ProxyViewOrderSource::Runtime
};
names.extend(core_groups.keys().filter(|name| !selected.contains(*name)).cloned());
let groups = names
.into_iter()
.filter_map(|name| {
core_groups
.remove(&name)
.map(|proxy| build_group(name, proxy, resolver))
})
.collect();
(groups, order_source)
}
fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
let mut standalone = ["DIRECT", "REJECT"]
.into_iter()
.filter_map(|name| core_node_ids.get(name).cloned())
.collect::<Vec<_>>();
standalone.extend(
core_node_ids
.iter()
.filter(|(name, _)| name.as_str() != "DIRECT" && name.as_str() != "REJECT")
.map(|(_, record_id)| record_id.clone()),
);
standalone
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use std::collections::HashMap;
use tauri_plugin_mihomo::models::{
Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, SubScriptionInfo, VehicleType,
};
use super::{
ProxyMemberRef, ProxyMemberUnresolvedReason, ProxyNodeSource, ProxyViewBuilder, ProxyViewInput,
ProxyViewOrderSource, ProxyViewProviderState,
};
fn node(name: &str) -> Proxy {
Proxy {
name: name.to_owned(),
proxy_type: ProxyType::Shadowsocks,
alive: true,
udp: true,
..Proxy::default()
}
}
fn group(name: &str, members: &[&str]) -> Proxy {
Proxy {
name: format!("ignored-{name}"),
proxy_type: ProxyType::Selector,
alive: true,
all: Some(members.iter().map(|name| (*name).to_owned()).collect()),
..Proxy::default()
}
}
fn ordered_input(reverse_insert: bool) -> ProxyViewInput {
let entries = [
("Zulu", group("Zulu", &["node-b", "node-b"])),
("Alpha", group("Alpha", &["node-a"])),
("node-b", node("wrong-node-b")),
("node-a", node("wrong-node-a")),
("A-before-direct", node("wrong-before-direct")),
("DIRECT", node("wrong-direct")),
("REJECT", node("wrong-reject")),
];
let mut proxies = HashMap::new();
if reverse_insert {
proxies.extend(entries.into_iter().rev().map(|(name, proxy)| (name.to_owned(), proxy)));
} else {
proxies.extend(entries.into_iter().map(|(name, proxy)| (name.to_owned(), proxy)));
}
ProxyViewInput {
runtime_group_order: vec!["Zulu".into(), "Alpha".into()],
proxies: Proxies { proxies },
providers: None,
}
}
fn providers_fixture(entries: Vec<(&str, VehicleType, &[&str])>) -> ProxyProviders {
let providers = entries
.into_iter()
.map(|(provider_key, vehicle_type, names)| {
let provider = ProxyProvider {
name: format!("ignored-{provider_key}"),
vehicle_type,
proxies: names.iter().map(|name| node(name)).collect(),
..ProxyProvider::default()
};
(provider_key.to_owned(), provider)
})
.collect();
ProxyProviders { providers }
}
fn input_with_members_and_duplicate_providers() -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["duplicate"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers_fixture(vec![
("a", VehicleType::HTTP, &["duplicate"]),
("b", VehicleType::File, &["duplicate"]),
])),
}
}
fn input_with_provider_member(providers: Option<ProxyProviders>) -> ProxyViewInput {
let proxies = HashMap::from([("Group".to_owned(), group("ignored", &["provider-only"]))]);
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers,
}
}
fn input_with_provider_duplicates() -> ProxyViewInput {
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![
("z-provider", VehicleType::HTTP, &["z-node"]),
("unsupported", VehicleType::Compatible, &["ignored"]),
("a-provider", VehicleType::File, &["same", "same"]),
])),
}
}
fn serde_input_with_provider() -> ProxyViewInput {
let provider = ProxyProvider {
name: "ignored-object-name".to_owned(),
vehicle_type: VehicleType::HTTP,
updated_at: None,
subscription_info: Some(SubScriptionInfo {
upload: 1,
download: 2,
total: 3,
expire: 4,
}),
..ProxyProvider::default()
};
ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(ProxyProviders {
providers: HashMap::from([("provider-key".to_owned(), provider)]),
}),
}
}
#[test]
fn ordering_and_core_records_are_deterministic() {
let first = ProxyViewBuilder::build(ordered_input(false));
let second = ProxyViewBuilder::build(ordered_input(true));
assert_eq!(first, second);
assert_eq!(first.order_source, ProxyViewOrderSource::Runtime);
assert_eq!(
first.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
assert_eq!(first.direct.as_deref(), Some("c:1"));
assert_eq!(first.standalone, ["c:1", "c:2", "c:0", "c:3", "c:4"]);
assert_eq!(first.records["c:0"].name, "A-before-direct");
assert_eq!(first.records["c:1"].name, "DIRECT");
assert_eq!(first.records["c:4"].name, "node-b");
assert_eq!(first.groups[0].members.len(), 2);
assert!(matches!(
&first.groups[0].members[0],
ProxyMemberRef::Node { name, .. } if name == "node-b"
));
assert_eq!(first.groups[0].members[0], first.groups[0].members[1]);
}
#[test]
fn fallback_and_unmatched_groups_are_stable() {
let mut input = ordered_input(false);
input.runtime_group_order = vec![];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Missing".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(view.order_source, ProxyViewOrderSource::Fallback);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Alpha", "Zulu"]
);
let mut input = ordered_input(false);
input.runtime_group_order = vec!["Zulu".into()];
let view = ProxyViewBuilder::build(input);
assert_eq!(
view.groups.iter().map(|group| group.name.as_str()).collect::<Vec<_>>(),
["Zulu", "Alpha"]
);
}
#[test]
fn global_and_member_resolution_follow_the_required_priority() {
let mut proxies = HashMap::new();
proxies.insert(
"GLOBAL".into(),
group("wrong", &["Nested", "core", "provider-only", "missing"]),
);
proxies.insert("Nested".into(), group("wrong", &[]));
proxies.insert("core".into(), node("wrong"));
let providers = providers_fixture(vec![
(
"a",
VehicleType::HTTP,
&["Nested", "core", "provider-only", "duplicate"],
),
("b", VehicleType::File, &["duplicate"]),
]);
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies { proxies },
providers: Some(providers),
});
let members = &view.global.as_ref().expect("GLOBAL").members;
assert!(matches!(members[0], ProxyMemberRef::Group { ref name } if name == "Nested"));
assert!(matches!(members[1], ProxyMemberRef::Node { ref name, .. } if name == "core"));
assert!(matches!(members[2], ProxyMemberRef::Node { ref name, .. } if name == "provider-only"));
assert!(matches!(
members[3],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Missing,
..
}
));
assert!(!view.groups.iter().any(|group| group.name == "GLOBAL"));
}
#[test]
fn ambiguous_and_provider_unavailable_are_not_reported_as_missing() {
let ready = ProxyViewBuilder::build(input_with_members_and_duplicate_providers());
assert!(matches!(
ready.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::Ambiguous,
..
}
));
let unavailable = ProxyViewBuilder::build(input_with_provider_member(None));
assert!(matches!(
unavailable.groups[0].members[0],
ProxyMemberRef::Unresolved {
reason: ProxyMemberUnresolvedReason::ProviderUnavailable,
..
}
));
assert_eq!(unavailable.provider_state, ProxyViewProviderState::Unavailable);
assert!(unavailable.providers.is_empty());
}
#[test]
fn providers_preserve_identity_duplicates_and_member_order() {
let view = ProxyViewBuilder::build(input_with_provider_duplicates());
assert_eq!(
view.providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["a-provider", "z-provider"]
);
assert_eq!(view.providers[0].proxy_record_ids, ["p:0:0", "p:0:1"]);
assert_eq!(view.records["p:0:0"].name, "same");
assert_eq!(view.records["p:0:1"].name, "same");
assert_eq!(
view.records["p:0:0"].source,
ProxyNodeSource::Provider {
provider_name: "a-provider".into(),
proxy_name: "same".into(),
}
);
}
#[test]
fn a_successful_response_with_only_unsupported_providers_is_ready_and_empty() {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec![],
proxies: Proxies::default(),
providers: Some(providers_fixture(vec![(
"unsupported",
VehicleType::Compatible,
&["ignored"],
)])),
});
assert_eq!(view.provider_state, ProxyViewProviderState::Ready);
assert!(view.providers.is_empty());
}
#[test]
fn serde_matches_the_v1_wire_contract() {
let view = ProxyViewBuilder::build(serde_input_with_provider());
let json = serde_json::to_value(view).expect("serialize view");
assert_eq!(json["schemaVersion"], 1);
assert_eq!(json["orderSource"], "fallback");
assert_eq!(json["providerState"], "ready");
assert!(json["global"].is_null());
assert!(json["direct"].is_null());
assert!(json["providers"][0].get("vehicleType").is_some());
assert!(json["providers"][0].get("updatedAt").is_none());
assert_eq!(json["providers"][0]["subscriptionInfo"]["upload"], 1);
assert!(json["providers"][0]["subscriptionInfo"].get("Upload").is_none());
}
}
+287
View File
@@ -0,0 +1,287 @@
//! The seam between Run State and the machine it observes.
//!
//! Two adapters satisfy it: [`RealEnv`] talks to the Service over IPC and to the platform's
//! service registry, [`FakeEnv`] replays scripted answers. Because the store is generic over
//! this trait rather than reaching for globals, the state machine can be exercised without
//! a running app, an installed Service, or elevation.
use anyhow::{Context as _, Result};
use super::health::{PendingAction, RunState};
use super::probe::ServiceVersionReply;
/// Everything Run State needs from outside itself.
pub trait RunStateEnv: Send + Sync + 'static {
/// Ask the Service which protocol it speaks. Fails when it cannot be reached at all.
fn probe_service_version(&self) -> impl Future<Output = Result<ServiceVersionReply>> + Send;
/// Whether the platform's service registry has a trusted record of an installation.
///
/// Fails when the registry itself could not be inspected, which is different from
/// "no installation" and is treated as unavailable rather than not-installed.
///
/// Async so blocking systemd and Windows SCM checks can run off the runtime workers.
fn trusted_install_evidence(&self) -> impl Future<Output = Result<bool>> + Send;
/// Whether this app process is running elevated.
fn is_elevated(&self) -> bool;
/// Open or close the local PAC endpoint.
///
/// Derived from the Running Mode, never set independently: a PAC script that points at a
/// proxy port nothing is listening on is worse than no PAC at all.
fn set_pac_available(&self, available: bool);
/// Publish the Run State to observers outside the store.
///
/// Two of them today: the diagnostic snapshot, which reports the Running Mode, and the
/// frontend, which gets the whole thing pushed rather than polling for it.
fn publish(&self, state: &RunState);
/// Carry out a privileged operation against the Service.
///
/// Blocking and platform-specific — SCM, systemd, launchd, elevation prompts — which is
/// exactly why it sits behind the seam: the state machine around it can then be exercised
/// without installing anything.
fn run_privileged(&self, action: PendingAction) -> Result<()>;
}
/// The production adapter: real IPC, real platform registry, real elevation check.
#[derive(Debug, Default, Clone, Copy)]
pub struct RealEnv;
impl RunStateEnv for RealEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
let response = clash_verge_service_ipc::get_version().await?;
Ok(ServiceVersionReply {
code: response.code,
message: response.message,
protocol: response.data,
})
}
async fn trusted_install_evidence(&self) -> Result<bool> {
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
.await
.context("service registration probe did not finish")?
}
fn is_elevated(&self) -> bool {
// Deliberately non-panicking, unlike `Handle::app_handle()`: Run State is read from
// startup paths that run before the app handle exists, and "not yet known" must
// degrade to "not elevated" rather than abort the process.
crate::APP_HANDLE
.get()
.is_some_and(tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin)
}
fn set_pac_available(&self, available: bool) {
crate::utils::server::set_pac_available(available);
}
fn publish(&self, state: &RunState) {
// Reconciling capability is a reaction to the new state, not part of publishing it, so
// it is spawned: a transition must not wait on a config write it triggered. It is also
// spawned *before* the app-handle gate below, because a Core that stops and starts
// before the app handle exists still needs reconciling; the task re-reads the Run
// State itself rather than closing over this snapshot.
crate::process::AsyncHandler::spawn(|| async {
crate::feat::reconcile_tun_availability().await;
});
// Non-panicking for the same reason as `is_elevated`: the Core can stop and start
// before the app handle exists, and a missed notification is better than an abort.
let Some(app_handle) = crate::APP_HANDLE.get() else {
return;
};
tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, state.mode.to_string());
crate::core::handle::Handle::notify_run_state(&state.to_view());
}
fn run_privileged(&self, action: PendingAction) -> Result<()> {
crate::core::service::run_privileged_service_action(action)
}
}
#[cfg(test)]
pub use fake::FakeEnv;
#[cfg(test)]
mod fake {
use anyhow::{Result, anyhow};
use clash_verge_service_ipc::ProtocolInfo;
use parking_lot::Mutex;
use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply};
use crate::core::manager::RunningMode;
/// A scripted stand-in for the machine.
///
/// Defaults to the least capable environment — no Service, no elevation — so a test only
/// has to say what it needs. Outbound effects are recorded rather than performed.
#[derive(Debug)]
pub struct FakeEnv {
version_replies: Mutex<Vec<Result<ServiceVersionReply, String>>>,
evidence: Result<bool, String>,
elevated: bool,
probe_count: Mutex<usize>,
pac_available: Mutex<Option<bool>>,
published: Mutex<Vec<RunState>>,
privileged_outcome: Mutex<Result<(), String>>,
privileged_actions: Mutex<Vec<PendingAction>>,
}
impl Default for FakeEnv {
fn default() -> Self {
Self {
version_replies: Mutex::new(Vec::new()),
evidence: Ok(false),
elevated: false,
probe_count: Mutex::new(0),
pac_available: Mutex::new(None),
published: Mutex::new(Vec::new()),
privileged_outcome: Mutex::new(Ok(())),
privileged_actions: Mutex::new(Vec::new()),
}
}
}
impl FakeEnv {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// An installed, reachable, protocol-compatible Service.
#[must_use]
pub fn service_ready(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
}))
}
/// An installed Service that answers with an incompatible protocol.
#[must_use]
pub fn service_version_mismatch(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: None,
}))
}
/// Registered with the platform but not answering.
#[must_use]
pub fn service_unreachable(self) -> Self {
self.with_evidence(true)
.always_replying(Err("ipc transport refused".to_owned()))
}
/// The platform registry itself could not be inspected.
#[must_use]
pub fn evidence_unavailable(mut self) -> Self {
self.evidence = Err("registry probe failed".to_owned());
self
}
#[must_use]
pub const fn elevated(mut self) -> Self {
self.elevated = true;
self
}
#[must_use]
pub fn with_evidence(mut self, exists: bool) -> Self {
self.evidence = Ok(exists);
self
}
/// Queue replies consumed one per probe; the last one repeats once exhausted.
#[must_use]
pub fn replying(self, replies: Vec<Result<ServiceVersionReply, String>>) -> Self {
*self.version_replies.lock() = replies;
self
}
#[must_use]
pub fn always_replying(self, reply: Result<ServiceVersionReply, String>) -> Self {
self.replying(vec![reply])
}
/// How many times the Service was probed — asserts that retries actually retried.
#[must_use]
pub fn probe_count(&self) -> usize {
*self.probe_count.lock()
}
/// The last PAC availability the store asked for, or `None` if it never asked.
#[must_use]
pub fn pac_available(&self) -> Option<bool> {
*self.pac_available.lock()
}
/// Every Running Mode published outward, in order.
#[must_use]
pub fn published_modes(&self) -> Vec<RunningMode> {
self.published.lock().iter().map(|state| state.mode).collect()
}
/// Every Run State published outward, in order.
#[must_use]
pub fn published(&self) -> Vec<RunState> {
self.published.lock().clone()
}
/// Make every privileged operation fail with `reason`.
#[must_use]
pub fn privileged_operations_fail(self, reason: &str) -> Self {
*self.privileged_outcome.lock() = Err(reason.to_owned());
self
}
/// Every privileged operation actually attempted, in order.
#[must_use]
pub fn privileged_actions(&self) -> Vec<PendingAction> {
self.privileged_actions.lock().clone()
}
}
impl RunStateEnv for FakeEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
*self.probe_count.lock() += 1;
let mut replies = self.version_replies.lock();
let reply = if replies.len() > 1 {
replies.remove(0)
} else {
replies
.first()
.cloned()
.unwrap_or_else(|| Err("no service configured".to_owned()))
};
reply.map_err(|error| anyhow!(error))
}
async fn trusted_install_evidence(&self) -> Result<bool> {
self.evidence.clone().map_err(|error| anyhow!(error))
}
fn is_elevated(&self) -> bool {
self.elevated
}
fn set_pac_available(&self, available: bool) {
*self.pac_available.lock() = Some(available);
}
fn publish(&self, state: &RunState) {
self.published.lock().push(state.clone());
}
fn run_privileged(&self, action: PendingAction) -> Result<()> {
self.privileged_actions.lock().push(action);
self.privileged_outcome.lock().clone().map_err(|error| anyhow!(error))
}
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Run State value types.
//!
//! The central distinction: [`ServiceHealth`] is what we *observed* about the Service,
//! [`PendingAction`] is what this session *asked for*. The legacy `ServiceStatus` enum
//! conflated the two into one slot, which is why narrowing it for the frontend had to be
//! lossy. Nothing narrows now: [`RunStateView`] carries both, plus the derived answers.
//!
//! This module owns state only. Starting, stopping and restarting the Core stay in
//! `CoreManager`: a module that both observes Service Health and drives the Core has no point
//! at which it is consistent, because a snapshot taken mid-restart would describe a transition
//! rather than a state.
use crate::core::manager::RunningMode;
/// What was last observed about the Service. A fact about the machine, never a request.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ServiceHealth {
/// Not probed yet.
#[default]
Unknown,
/// Installed, reachable, and speaking a compatible protocol.
Ready,
/// No trusted installation evidence on this machine.
NotInstalled,
/// Installed but speaking an incompatible protocol; needs reinstall.
VersionMismatch,
/// Installed but unusable, with the reason we recorded.
Unavailable(String),
}
impl ServiceHealth {
/// The variant name alone, for callers that carry the reason separately.
const fn kind(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Ready => "ready",
Self::NotInstalled => "notInstalled",
Self::VersionMismatch => "versionMismatch",
Self::Unavailable(_) => "unavailable",
}
}
fn reason(&self) -> Option<String> {
match self {
Self::Unavailable(reason) => Some(reason.clone()),
_ => None,
}
}
}
/// A privileged operation this session asked for against the Service.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PendingAction {
Install,
Uninstall,
Reinstall,
ForceReinstall,
}
/// The single consistent answer to "how is the Core running, and what backs it".
///
/// Every question about Core availability is answered by a method here, so that no
/// caller writes its own formula. Construct one via `RunStateStore::state`/`settled`/`probe`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunState {
pub health: ServiceHealth,
pub pending: Option<PendingAction>,
pub sidecar_allowed: bool,
pub mode: RunningMode,
pub is_admin: bool,
/// A privileged operation is currently in flight, so `health` may be stale.
pub op_in_flight: bool,
}
impl RunState {
/// The Service reported itself ready the last time we looked.
///
/// Says nothing about in-flight operations — prefer [`Self::service_usable`] when
/// deciding whether to rely on the Service right now.
#[must_use]
pub const fn service_ready(&self) -> bool {
matches!(self.health, ServiceHealth::Ready)
}
/// The Service can be relied upon right now: ready, and not mid-operation.
#[must_use]
pub const fn service_usable(&self) -> bool {
self.service_ready() && !self.op_in_flight
}
/// TUN mode can work right now — either the app is elevated, or the Service backs it.
#[must_use]
pub const fn tun_capable(&self) -> bool {
self.is_admin || self.service_usable()
}
/// The Service cannot be used and the user has to choose what to do about it.
///
/// A requested action always needs an answer — unless it is already being carried out,
/// which is a report of progress rather than a question. The legacy status could not tell
/// the two apart because readers blocked until the operation finished and so never saw the
/// request at all; now that every transition is pushed, an install started from the
/// settings page would otherwise raise the migration dialog on top of it, and an uninstall
/// would raise one offering to reinstall the very Service being removed.
///
/// Otherwise it is only the states where the Service is present but unusable: a Service
/// that is simply absent, or a session that has already settled on Sidecar, needs nothing
/// from anyone.
#[must_use]
pub const fn service_needs_attention(&self) -> bool {
if self.op_in_flight {
return false;
}
if self.pending.is_some() {
return true;
}
if self.sidecar_allowed {
return false;
}
matches!(
self.health,
ServiceHealth::VersionMismatch | ServiceHealth::Unavailable(_)
)
}
/// TUN is switched on but cannot work, and we know that for certain.
///
/// "For certain" is the whole difficulty: an unprobed Service, an operation in flight, or a
/// decision still sitting in front of the user all mean *not yet known*, and turning TUN
/// off on a guess would silently undo something the user asked for. This replaces the fixed
/// startup grace period the frontend used to wait out, which guessed at the same thing.
#[must_use]
pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool {
if !tun_enabled || self.tun_capable() {
return false;
}
!matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention()
}
/// The shape sent across the IPC seam.
///
/// Carries the derived answers, not just the raw fields, so that no caller on the other
/// side reinvents `tun_capable` or the "needs attention" ladder.
#[must_use]
pub fn to_view(&self) -> RunStateView {
RunStateView {
mode: self.mode,
service: self.health.kind(),
service_unavailable_reason: self.health.reason(),
pending_action: self.pending,
sidecar_allowed: self.sidecar_allowed,
is_admin: self.is_admin,
op_in_flight: self.op_in_flight,
service_usable: self.service_usable(),
tun_capable: self.tun_capable(),
service_needs_attention: self.service_needs_attention(),
}
}
}
/// The Run State as the frontend sees it.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunStateView {
pub mode: RunningMode,
pub service: &'static str,
pub service_unavailable_reason: Option<String>,
pub pending_action: Option<PendingAction>,
pub sidecar_allowed: bool,
pub is_admin: bool,
pub op_in_flight: bool,
pub service_usable: bool,
pub tun_capable: bool,
pub service_needs_attention: bool,
}
/// The service-owned part of the Run State, stored behind one lock.
///
/// At most one of `pending` / `sidecar_allowed` is set at a time; the transitions in
/// [`super::RunStateStore`] maintain that, mirroring the single-slot semantics the legacy
/// `ServiceStatus` had.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct StoredService {
pub health: ServiceHealth,
pub pending: Option<PendingAction>,
pub sidecar_allowed: bool,
}
impl StoredService {
/// Record an observation, clearing any request it answers.
pub fn observe(&mut self, health: ServiceHealth) {
self.health = health;
self.pending = None;
self.sidecar_allowed = false;
}
/// Record a requested privileged operation, preserving the last observation.
pub const fn request(&mut self, action: PendingAction) {
self.pending = Some(action);
self.sidecar_allowed = false;
}
/// Record that the user accepted Sidecar for this session.
pub const fn allow_sidecar(&mut self) {
self.pending = None;
self.sidecar_allowed = true;
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::*;
fn state(health: ServiceHealth, is_admin: bool, op_in_flight: bool) -> RunState {
RunState {
health,
pending: None,
sidecar_allowed: false,
mode: RunningMode::NotRunning,
is_admin,
op_in_flight,
}
}
#[test]
fn service_usable_requires_no_operation_in_flight() {
assert!(state(ServiceHealth::Ready, false, false).service_usable());
assert!(!state(ServiceHealth::Ready, false, true).service_usable());
assert!(!state(ServiceHealth::NotInstalled, false, false).service_usable());
}
#[test]
fn service_ready_ignores_operations_in_flight() {
assert!(state(ServiceHealth::Ready, false, true).service_ready());
}
#[test]
fn tun_capable_when_elevated_even_without_service() {
assert!(state(ServiceHealth::NotInstalled, true, false).tun_capable());
assert!(state(ServiceHealth::Unavailable("boom".into()), true, true).tun_capable());
assert!(!state(ServiceHealth::NotInstalled, false, false).tun_capable());
}
#[test]
fn a_service_that_is_merely_absent_needs_no_decision() {
assert!(!state(ServiceHealth::NotInstalled, false, false).service_needs_attention());
assert!(!state(ServiceHealth::Ready, false, false).service_needs_attention());
assert!(!state(ServiceHealth::Unknown, false, false).service_needs_attention());
}
#[test]
fn a_present_but_unusable_service_needs_a_decision() {
assert!(state(ServiceHealth::VersionMismatch, false, false).service_needs_attention());
assert!(state(ServiceHealth::Unavailable("boom".into()), false, false).service_needs_attention());
}
#[test]
fn a_requested_action_needs_an_answer_until_it_is_being_carried_out() {
for action in [
PendingAction::Install,
PendingAction::Uninstall,
PendingAction::Reinstall,
PendingAction::ForceReinstall,
] {
let mut run_state = state(ServiceHealth::NotInstalled, false, false);
run_state.pending = Some(action);
assert!(run_state.service_needs_attention(), "{action:?} is still a question");
run_state.op_in_flight = true;
assert!(
!run_state.service_needs_attention(),
"{action:?} is under way, which is progress rather than a question"
);
}
}
#[test]
fn an_operation_under_way_is_never_a_decision_to_put_to_the_user() {
// The regression this pins: an install or uninstall started from the settings page
// pushes its pending action to the frontend the moment it is requested. Readers used
// to block until the operation finished and so never saw it; now that every transition
// is pushed, treating it as a decision raises the migration dialog on top of an
// operation already running — and for an uninstall, one offering to reinstall.
for health in [
ServiceHealth::Unknown,
ServiceHealth::Ready,
ServiceHealth::NotInstalled,
ServiceHealth::VersionMismatch,
ServiceHealth::Unavailable("boom".into()),
] {
let mut run_state = state(health.clone(), false, true);
run_state.pending = Some(PendingAction::Uninstall);
assert!(!run_state.service_needs_attention(), "{health:?}");
}
}
#[test]
fn a_session_that_settled_on_sidecar_needs_nothing() {
let mut run_state = state(ServiceHealth::VersionMismatch, false, false);
run_state.sidecar_allowed = true;
assert!(!run_state.service_needs_attention());
}
#[test]
fn the_view_carries_the_derived_answers_and_the_unavailable_reason() {
let mut run_state = state(ServiceHealth::Unavailable("socket refused".into()), true, false);
run_state.pending = Some(PendingAction::Reinstall);
let view = run_state.to_view();
assert_eq!(view.service, "unavailable");
assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused"));
assert_eq!(view.pending_action, Some(PendingAction::Reinstall));
assert!(view.tun_capable, "elevation alone makes TUN possible");
assert!(!view.service_usable);
assert!(view.service_needs_attention);
}
#[test]
fn a_healthy_view_reports_no_unavailable_reason() {
let view = state(ServiceHealth::Ready, false, false).to_view();
assert_eq!(view.service, "ready");
assert_eq!(view.service_unavailable_reason, None);
assert!(view.service_usable);
assert!(!view.service_needs_attention);
}
#[test]
fn tun_is_left_alone_when_the_user_has_not_asked_for_it() {
assert!(!state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(false));
}
#[test]
fn tun_is_left_alone_while_it_still_works() {
assert!(!state(ServiceHealth::Ready, false, false).tun_should_be_disabled(true));
assert!(!state(ServiceHealth::NotInstalled, true, false).tun_should_be_disabled(true));
}
#[test]
fn tun_is_disabled_once_we_know_it_cannot_work() {
assert!(state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(true));
}
#[test]
fn tun_is_never_disabled_while_an_operation_is_in_flight() {
// Mid-install the service is not usable, but that says nothing about what it will be
// once the operation finishes.
assert!(!state(ServiceHealth::Ready, false, true).tun_should_be_disabled(true));
assert!(!state(ServiceHealth::NotInstalled, false, true).tun_should_be_disabled(true));
}
#[test]
fn tun_is_never_disabled_while_the_core_is_not_running() {
// The startup regression this guards: the service is observed as absent before the
// startup path has decided whether to install it, and acting on that snapshot both
// rewrites the user's setting and removes the prompt that would have fixed it.
// `reconcile_tun_availability` refuses to act at all while the mode is NotRunning;
// this pins the state that made it dangerous.
let stopped = state(ServiceHealth::NotInstalled, false, false);
assert_eq!(stopped.mode, RunningMode::NotRunning);
assert!(
stopped.tun_should_be_disabled(true),
"the predicate alone says yes, which is why the caller must gate on the mode"
);
}
#[test]
fn tun_is_never_disabled_on_an_unprobed_service() {
// The old frontend guard waited out a fixed ten seconds to approximate this.
assert!(!state(ServiceHealth::Unknown, false, false).tun_should_be_disabled(true));
}
#[test]
fn tun_is_never_disabled_while_a_decision_is_pending() {
for health in [
ServiceHealth::VersionMismatch,
ServiceHealth::Unavailable("boom".into()),
] {
assert!(
!state(health.clone(), false, false).tun_should_be_disabled(true),
"{health:?} is the user's to resolve",
);
}
let mut requested = state(ServiceHealth::NotInstalled, false, false);
requested.pending = Some(PendingAction::Install);
assert!(!requested.tun_should_be_disabled(true));
}
#[test]
fn tun_is_disabled_once_the_user_settles_on_sidecar() {
let mut settled = state(ServiceHealth::NotInstalled, false, false);
settled.sidecar_allowed = true;
assert!(settled.tun_should_be_disabled(true));
}
#[test]
fn observing_clears_a_pending_request() {
let mut stored = StoredService::default();
stored.request(PendingAction::Install);
stored.observe(ServiceHealth::Ready);
assert_eq!(stored.pending, None);
assert!(!stored.sidecar_allowed);
assert_eq!(stored.health, ServiceHealth::Ready);
}
#[test]
fn requesting_and_allowing_sidecar_are_mutually_exclusive() {
let mut stored = StoredService::default();
stored.observe(ServiceHealth::NotInstalled);
stored.request(PendingAction::Install);
assert!(!stored.sidecar_allowed);
stored.allow_sidecar();
assert_eq!(stored.pending, None);
assert!(stored.sidecar_allowed);
// The observation survives both requests.
assert_eq!(stored.health, ServiceHealth::NotInstalled);
}
}
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
//! Deciding when the Service has stopped being ours.
//!
//! While the Core runs under the Service, the app polls the Service for owner status. Three
//! things can go wrong — we cannot read a reply, the Service says someone else owns it, or it
//! answers but describes a Core that is gone — and each needs a different response. This is
//! the decision, separated from the polling and from the recovery it triggers.
//!
//! Recovery *actions* deliberately live with the Core lifecycle rather than here: this module
//! decides that the Service can no longer be trusted, and `CoreManager` is what tears the Core
//! down in response.
use clash_verge_service_ipc::ServiceLifecycleState;
/// How many consecutive bad samples we tolerate before acting.
///
/// Service restarts and slow status calls are normal; acting on the first one would tear down
/// a working proxy for a blip.
const SUSTAINED_SAMPLES: u8 = 3;
/// Why the app stopped trusting the Service that was running its Core.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerRecoveryReason {
/// Another owner took the Service over.
Displaced,
/// Still ours, but the Core it was running is gone.
SameOwnerFailure,
/// We could not reach the Service at all for long enough to give up.
TransportFailure,
}
/// One reading of Service owner status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerSample {
/// No usable answer: transport failed, an error code came back, or the payload was empty.
Unreadable,
/// The Service reports it is no longer running for us.
NotActive,
/// A complete answer.
Status {
is_active: bool,
desired_core_should_be_running: bool,
service_state: ServiceLifecycleState,
core_pid: Option<u32>,
},
}
/// What the caller should do about a sample.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerStep {
/// Nothing is wrong, or not wrong for long enough yet.
Continue,
/// Unreadable for long enough that it might be real. Check whether the Core's own endpoint
/// still answers, then report back via [`OwnerWatch::resolve_transport`].
///
/// A Service we cannot reach while the Core is still serving is a broken status channel,
/// not a lost Core — tearing down the proxy there would be the cure causing the disease.
VerifyTransport,
/// Stop trusting the Service and recover.
Recover(OwnerRecoveryReason),
}
/// Counts consecutive bad samples so that a blip is not mistaken for a failure.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct OwnerWatch {
unreadable_samples: u8,
missing_core_samples: u8,
}
impl OwnerWatch {
#[must_use]
pub const fn new() -> Self {
Self {
unreadable_samples: 0,
missing_core_samples: 0,
}
}
/// Whether this sample warrants a log line: only the moment a run becomes sustained, so a
/// permanently broken Service does not fill the log at the poll interval.
#[must_use]
pub const fn just_became_sustained(&self) -> bool {
self.unreadable_samples == SUSTAINED_SAMPLES
}
/// Fold one reading into the decision.
pub const fn observe(&mut self, sample: OwnerSample) -> OwnerStep {
match sample {
OwnerSample::Unreadable => {
self.unreadable_samples = self.unreadable_samples.saturating_add(1);
if self.unreadable_samples >= SUSTAINED_SAMPLES {
OwnerStep::VerifyTransport
} else {
OwnerStep::Continue
}
}
OwnerSample::NotActive => OwnerStep::Recover(OwnerRecoveryReason::Displaced),
OwnerSample::Status {
is_active,
desired_core_should_be_running,
service_state,
core_pid,
} => {
self.unreadable_samples = 0;
self.missing_core_samples = if core_pid.is_none() && !is_settling(service_state) {
self.missing_core_samples.saturating_add(1)
} else {
0
};
match recovery_reason(
is_active,
desired_core_should_be_running,
service_state,
core_pid,
self.missing_core_samples,
) {
Some(reason) => OwnerStep::Recover(reason),
None => OwnerStep::Continue,
}
}
}
}
/// Report what the transport check found after [`OwnerStep::VerifyTransport`].
///
/// If the Core's own endpoint still answers, only the status channel is broken: the run is
/// reset so the next stretch of silence has to earn its own verdict.
pub const fn resolve_transport(&mut self, owner_endpoint_available: bool) -> OwnerStep {
if owner_endpoint_available {
self.unreadable_samples = 0;
return OwnerStep::Continue;
}
OwnerStep::Recover(OwnerRecoveryReason::TransportFailure)
}
}
/// States in which a missing Core PID is expected rather than alarming.
const fn is_settling(service_state: ServiceLifecycleState) -> bool {
matches!(
service_state,
ServiceLifecycleState::Starting | ServiceLifecycleState::RecoveringCore
)
}
const fn recovery_reason(
is_active: bool,
desired_running: bool,
service_state: ServiceLifecycleState,
core_pid: Option<u32>,
missing_core_samples: u8,
) -> Option<OwnerRecoveryReason> {
if !is_active {
return Some(OwnerRecoveryReason::Displaced);
}
let core_is_gone = !is_settling(service_state) && core_pid.is_none() && missing_core_samples >= SUSTAINED_SAMPLES;
if !desired_running || matches!(service_state, ServiceLifecycleState::Fatal) || core_is_gone {
return Some(OwnerRecoveryReason::SameOwnerFailure);
}
None
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::*;
const fn healthy() -> OwnerSample {
OwnerSample::Status {
is_active: true,
desired_core_should_be_running: true,
service_state: ServiceLifecycleState::Running,
core_pid: Some(42),
}
}
const fn core_gone(service_state: ServiceLifecycleState) -> OwnerSample {
OwnerSample::Status {
is_active: true,
desired_core_should_be_running: true,
service_state,
core_pid: None,
}
}
#[test]
fn a_healthy_sample_changes_nothing() {
let mut watch = OwnerWatch::new();
assert_eq!(watch.observe(healthy()), OwnerStep::Continue);
assert_eq!(watch, OwnerWatch::new());
}
#[test]
fn a_short_run_of_unreadable_samples_is_tolerated() {
let mut watch = OwnerWatch::new();
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
}
#[test]
fn a_sustained_run_of_unreadable_samples_asks_about_the_transport() {
let mut watch = OwnerWatch::new();
for _ in 0..2 {
watch.observe(OwnerSample::Unreadable);
}
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport);
assert!(watch.just_became_sustained(), "the sustained moment should log once");
}
#[test]
fn a_sustained_run_logs_only_once_however_long_it_lasts() {
let mut watch = OwnerWatch::new();
for _ in 0..3 {
watch.observe(OwnerSample::Unreadable);
}
assert!(watch.just_became_sustained());
watch.observe(OwnerSample::Unreadable);
assert!(!watch.just_became_sustained());
}
#[test]
fn a_reachable_core_endpoint_means_only_the_status_channel_broke() {
let mut watch = OwnerWatch::new();
for _ in 0..3 {
watch.observe(OwnerSample::Unreadable);
}
assert_eq!(watch.resolve_transport(true), OwnerStep::Continue);
// The run restarted: the next stretch of silence must earn its own verdict.
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport);
}
#[test]
fn an_unreachable_core_endpoint_confirms_transport_failure() {
let mut watch = OwnerWatch::new();
for _ in 0..3 {
watch.observe(OwnerSample::Unreadable);
}
assert_eq!(
watch.resolve_transport(false),
OwnerStep::Recover(OwnerRecoveryReason::TransportFailure)
);
}
#[test]
fn a_readable_sample_clears_an_unreadable_run() {
let mut watch = OwnerWatch::new();
watch.observe(OwnerSample::Unreadable);
watch.observe(OwnerSample::Unreadable);
watch.observe(healthy());
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue);
}
#[test]
fn a_service_reporting_not_active_is_displacement() {
let mut watch = OwnerWatch::new();
assert_eq!(
watch.observe(OwnerSample::NotActive),
OwnerStep::Recover(OwnerRecoveryReason::Displaced)
);
}
#[test]
fn a_status_saying_we_are_not_the_owner_is_displacement() {
let mut watch = OwnerWatch::new();
let sample = OwnerSample::Status {
is_active: false,
desired_core_should_be_running: true,
service_state: ServiceLifecycleState::Running,
core_pid: Some(1),
};
assert_eq!(
watch.observe(sample),
OwnerStep::Recover(OwnerRecoveryReason::Displaced)
);
}
#[test]
fn a_fatal_service_recovers_immediately() {
let mut watch = OwnerWatch::new();
let sample = OwnerSample::Status {
is_active: true,
desired_core_should_be_running: true,
service_state: ServiceLifecycleState::Fatal,
core_pid: Some(1),
};
assert_eq!(
watch.observe(sample),
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
);
}
#[test]
fn a_service_that_no_longer_wants_the_core_running_recovers_immediately() {
let mut watch = OwnerWatch::new();
let sample = OwnerSample::Status {
is_active: true,
desired_core_should_be_running: false,
service_state: ServiceLifecycleState::Running,
core_pid: Some(1),
};
assert_eq!(
watch.observe(sample),
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
);
}
#[test]
fn a_briefly_missing_core_is_tolerated_then_recovered() {
let mut watch = OwnerWatch::new();
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Continue
);
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Continue
);
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure)
);
}
#[test]
fn a_missing_core_is_expected_while_the_service_is_still_settling() {
for state in [ServiceLifecycleState::Starting, ServiceLifecycleState::RecoveringCore] {
let mut watch = OwnerWatch::new();
for _ in 0..10 {
assert_eq!(watch.observe(core_gone(state)), OwnerStep::Continue, "{state:?}");
}
}
}
#[test]
fn a_core_that_comes_back_clears_the_missing_run() {
let mut watch = OwnerWatch::new();
watch.observe(core_gone(ServiceLifecycleState::Running));
watch.observe(core_gone(ServiceLifecycleState::Running));
watch.observe(healthy());
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Continue
);
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Continue
);
}
#[test]
fn an_unreadable_run_does_not_count_towards_a_missing_core() {
// The two counters are independent: silence says nothing about the Core's PID.
let mut watch = OwnerWatch::new();
watch.observe(OwnerSample::Unreadable);
watch.observe(OwnerSample::Unreadable);
assert_eq!(
watch.observe(core_gone(ServiceLifecycleState::Running)),
OwnerStep::Continue
);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Turning raw Service probes into [`ServiceHealth`].
//!
//! Everything here is a pure function of its arguments: the probing itself is an effect
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
//! systemd, SCM or launchd.
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
use super::health::ServiceHealth;
/// What the Service replied to a protocol-version query.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceVersionReply {
pub code: u16,
pub message: String,
pub protocol: Option<ProtocolInfo>,
}
/// The verdict on a [`ServiceVersionReply`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceVersionCheck {
Ready,
NeedsReinstall(String),
}
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
Missing,
Ready,
VersionMismatch,
Unavailable,
}
/// Decide whether the Service we reached speaks a protocol this client can use.
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
let client = ProtocolVersion::current();
if reply.code == 0
&& reply
.protocol
.as_ref()
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
{
return ServiceVersionCheck::Ready;
}
let detail = if reply.code == 0 {
match reply.protocol.as_ref() {
Some(info) => format!(
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
client.epoch,
MIN_REQUIRED_SERVICE_REVISION,
info.protocol.epoch,
info.protocol.revision,
info.build_version
),
None => "service did not report protocol information".to_owned(),
}
} else {
format!(
"protocol query returned code {} ({}) while expecting epoch {}",
reply.code, reply.message, client.epoch
)
};
ServiceVersionCheck::NeedsReinstall(format!(
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
))
}
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
CurrentServiceProbe::Ready
} else {
CurrentServiceProbe::VersionMismatch
}
}
/// Combine a live probe with platform installation evidence into a health verdict.
///
/// The `has_install_marker` case matters: a Service registered with the platform that does
/// not answer is broken, not absent, so it needs reinstalling rather than installing.
/// `unavailable_reason` is only consulted for [`CurrentServiceProbe::Unavailable`].
pub fn classify_service_health(
current: CurrentServiceProbe,
has_install_marker: bool,
unavailable_reason: &str,
) -> ServiceHealth {
match current {
CurrentServiceProbe::Ready => ServiceHealth::Ready,
CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.to_owned()),
CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Missing => ServiceHealth::NotInstalled,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::*;
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
}
}
#[test]
fn a_compatible_reply_is_ready() {
assert_eq!(
classify_service_version_reply(&ready_reply()),
ServiceVersionCheck::Ready
);
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
}
#[test]
fn a_reply_without_protocol_information_needs_reinstall() {
let reply = ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: None,
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
panic!("expected a reinstall verdict");
};
assert!(
detail.contains("did not report protocol information"),
"detail should explain the missing protocol: {detail}"
);
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch);
}
#[test]
fn a_nonzero_code_needs_reinstall() {
let reply = ServiceVersionReply {
code: 7,
message: "nope".to_owned(),
protocol: Some(ProtocolInfo::current()),
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
panic!("expected a reinstall verdict");
};
assert!(detail.contains("code 7"), "detail should name the code: {detail}");
}
#[test]
fn a_mismatched_epoch_needs_reinstall() {
let mut info = ProtocolInfo::current();
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
let reply = ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
};
assert!(matches!(
classify_service_version_reply(&reply),
ServiceVersionCheck::NeedsReinstall(_)
));
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch);
}
#[test]
fn a_service_revision_below_the_minimum_needs_reinstall() {
let Some(revision) = MIN_REQUIRED_SERVICE_REVISION.checked_sub(1) else {
// Nothing below the minimum exists to test against.
return;
};
let mut info = ProtocolInfo::current();
info.protocol.revision = revision;
let reply = ServiceVersionReply {
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
};
assert!(matches!(
classify_service_version_reply(&reply),
ServiceVersionCheck::NeedsReinstall(_)
));
}
#[test]
fn every_rejection_tells_the_user_what_to_do_about_it() {
let mut old_epoch = ProtocolInfo::current();
old_epoch.protocol.epoch = old_epoch.protocol.epoch.wrapping_add(1);
for protocol in [None, Some(old_epoch)] {
for code in [0, 1] {
let reply = ServiceVersionReply {
code,
message: "unsupported command".to_owned(),
protocol: protocol.clone(),
};
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
continue;
};
assert!(
detail.contains("epoch") || detail.contains("protocol"),
"detail should say what mismatched: {detail}"
);
assert!(
detail.contains("Reinstall") || detail.contains("Repair"),
"detail should name the action that fixes it: {detail}"
);
}
}
}
#[test]
fn missing_without_evidence_is_not_installed() {
assert_eq!(
classify_service_health(CurrentServiceProbe::Missing, false, ""),
ServiceHealth::NotInstalled
);
}
#[test]
fn missing_with_evidence_means_broken_not_absent() {
assert_eq!(
classify_service_health(CurrentServiceProbe::Missing, true, ""),
ServiceHealth::VersionMismatch
);
}
#[test]
fn an_unreachable_service_keeps_the_reason() {
assert_eq!(
classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"),
ServiceHealth::Unavailable("socket refused".to_owned())
);
}
#[test]
fn a_live_probe_dominates_the_install_marker() {
for has_marker in [false, true] {
assert_eq!(
classify_service_health(CurrentServiceProbe::Ready, has_marker, ""),
ServiceHealth::Ready
);
assert_eq!(
classify_service_health(CurrentServiceProbe::VersionMismatch, has_marker, ""),
ServiceHealth::VersionMismatch
);
}
}
}
+412
View File
@@ -0,0 +1,412 @@
use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::Value;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
"geoip.dat",
"geosite.dat",
"geoip.metadb",
"GeoSite.dat",
];
pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) -> Result<RuntimeBundle> {
let yaml = tokio::fs::read_to_string(config_file)
.await
.with_context(|| format!("failed to read runtime config {config_file:?}"))?;
let mut config: Value =
serde_yaml_ng::from_str(&yaml).with_context(|| format!("failed to parse runtime config {config_file:?}"))?;
let config_root = config_file
.parent()
.ok_or_else(|| anyhow::anyhow!("runtime config has no parent directory"))?;
let config_root = std::fs::canonicalize(config_root)?;
let mut assets = Vec::new();
let mut destinations = HashSet::new();
let mut remote_providers = Vec::new();
collect_provider_assets(
&mut config,
"proxy-providers",
&config_root,
&mut destinations,
&mut assets,
&mut remote_providers,
)?;
collect_provider_assets(
&mut config,
"rule-providers",
&config_root,
&mut destinations,
&mut assets,
&mut remote_providers,
)?;
for filename in GEO_ASSETS {
let source = config_root.join(filename);
if source.is_file() && destinations.insert((*filename).to_string()) {
assets.push(RuntimeAsset {
source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(),
destination: (*filename).to_string(),
});
}
}
Ok(RuntimeBundle {
yaml: serde_yaml_ng::to_string(&config).context("failed to serialize service runtime config")?,
assets,
remote_providers,
core_path: core_path.to_string_lossy().into_owned(),
})
}
/// Rewrite one provider section's paths to service-side destinations, and record what each
/// destination is going to hold.
///
/// The two outputs are not the same thing. `assets` are files the app owns and the service copies;
/// `remote_providers` are files the *core* downloads, which the service never writes and only
/// needs to recognise — a download cache is reusable exactly while the url that produced it is
/// unchanged, and the service has no other way to tell.
fn collect_provider_assets(
config: &mut Value,
section: &str,
config_root: &Path,
destinations: &mut HashSet<String>,
assets: &mut Vec<RuntimeAsset>,
remote_providers: &mut Vec<RemoteProvider>,
) -> Result<()> {
let Some(providers) = config
.as_mapping_mut()
.and_then(|mapping| mapping.get_mut(section))
.and_then(Value::as_mapping_mut)
else {
return Ok(());
};
for (name, provider) in providers.iter_mut() {
let Some(provider) = provider.as_mapping_mut() else {
continue;
};
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
continue;
};
// The Core decides what a provider is by its `type`, so anything else here would disagree
// with it. Keying off the presence of a `url` used to do that: a `file` provider carrying
// a stray `url` was treated as remote, its local file left uncopied, and the Core then
// could not find what the rewritten path pointed at.
//
// Disagreeing the other way is just as bad, though, and it is why neither branch may fail
// the whole bundle over one odd provider. Measured against mihomo v1.19.26: it accepts a
// `http` provider with no url, an `inline` provider carrying both a url and a path that
// does not exist, and a `file` provider whose path is missing. Refusing any of those here
// would turn a configuration the Core is willing to load into one that cannot be applied
// at all — including at start, since this is where a Service-mode start gets its bundle.
// A provider we cannot classify keeps its path unrewritten and is simply not declared.
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
let url = provider.get("url").and_then(Value::as_str).map(str::to_owned);
let destination = match (is_remote, url) {
(true, Some(url)) => {
let destination = provider_destination(config_root, raw_path)?;
// Remote providers take their destination out of circulation as much as local ones
// do: the Service refuses a bundle where one path is claimed both by a file it must
// copy and by a file the Core downloads, because the two would overwrite each
// other. Two providers naming the same file *and* the same source are not that —
// they are one file with two names for it, which the Service folds.
match remote_providers
.iter()
.find(|declared| declared.destination == destination)
{
Some(declared) if declared.url == url => {}
Some(_) => {
bail!("runtime provider destination {destination:?} is declared for two different sources")
}
None => {
if !destinations.insert(destination.clone()) {
bail!("runtime provider destination {destination:?} is claimed more than once");
}
remote_providers.push(RemoteProvider {
destination: destination.clone(),
url,
});
}
}
destination
}
(true, None) => {
logging!(
warn,
Type::Config,
"remote provider {name:?} declares no url; leaving its path to the core"
);
continue;
}
(false, _) => {
let Ok(source) = local_provider_source(config_root, raw_path) else {
logging!(
warn,
Type::Config,
"local provider {name:?} is unavailable at {raw_path:?}; leaving its path to the core"
);
continue;
};
let destination = destination_below_root(config_root, &source)?;
if destinations.insert(destination.clone()) {
assets.push(RuntimeAsset {
source: source.to_string_lossy().into_owned(),
destination: destination.clone(),
});
}
destination
}
};
provider.insert(Value::String("path".to_owned()), Value::String(destination));
}
Ok(())
}
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
let path = Path::new(raw_path);
let source = if path.is_absolute() {
path.to_path_buf()
} else {
config_root.join(path)
};
let canonical_source =
std::fs::canonicalize(&source).with_context(|| format!("local runtime provider is unavailable: {source:?}"))?;
canonical_source
.strip_prefix(config_root)
.map_err(|_| anyhow::anyhow!("local runtime provider is outside the config root: {canonical_source:?}"))?;
Ok(canonical_source)
}
fn provider_destination(config_root: &Path, raw_path: &str) -> Result<String> {
let path = Path::new(raw_path);
if path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
bail!("runtime provider destination traverses outside the runtime");
}
if path.is_absolute() {
let normalized = canonicalize_with_missing_tail(path)?;
destination_below_root(config_root, &normalized)
} else {
normalized_destination(path)
}
}
fn canonicalize_with_missing_tail(path: &Path) -> Result<PathBuf> {
let mut ancestor = path;
let mut tail = Vec::new();
while !ancestor.exists() {
let name = ancestor
.file_name()
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
tail.push(name.to_owned());
ancestor = ancestor
.parent()
.ok_or_else(|| anyhow::anyhow!("runtime provider path has no existing ancestor: {path:?}"))?;
}
let mut normalized = std::fs::canonicalize(ancestor)?;
for component in tail.iter().rev() {
normalized.push(component);
}
Ok(normalized)
}
fn destination_below_root(config_root: &Path, path: &Path) -> Result<String> {
let relative = path
.strip_prefix(config_root)
.map_err(|_| anyhow::anyhow!("runtime provider path is outside the config root: {path:?}"))?;
normalized_destination(relative)
}
fn normalized_destination(relative: &Path) -> Result<String> {
let mut destination = PathBuf::new();
for component in relative.components() {
match component {
Component::Normal(component) => destination.push(component),
Component::CurDir => {}
_ => bail!("local runtime provider destination traverses outside the runtime"),
}
}
if destination.as_os_str().is_empty() {
bail!("local runtime provider destination is empty");
}
Ok(destination.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::collect_runtime_bundle;
#[tokio::test]
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-{}", std::process::id()));
std::fs::create_dir_all(root.join("providers"))?;
std::fs::write(root.join("providers/local.yaml"), b"proxies: []\n")?;
std::fs::write(root.join("Country.mmdb"), b"geo")?;
let config = root.join("config.yaml");
let absolute_local = root.join("providers/local.yaml");
let absolute_remote = root.join("providers/remote.yaml");
std::fs::write(
&config,
format!(
"proxy-providers:\n local:\n type: file\n path: {}\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\nrule-providers: {{}}\n",
absolute_local.display(),
absolute_remote.display()
),
)?;
let core = root.join("mihomo");
std::fs::write(&core, b"core")?;
let bundle = collect_runtime_bundle(&config, &core).await?;
let bundled_config: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?;
assert_eq!(
bundled_config["proxy-providers"]["local"]["path"].as_str(),
Some("providers/local.yaml")
);
assert_eq!(
bundled_config["proxy-providers"]["remote"]["path"].as_str(),
Some("providers/remote.yaml")
);
assert!(!bundle.yaml.contains(&root.to_string_lossy().into_owned()));
assert!(
bundle
.assets
.iter()
.any(|asset| asset.destination == "providers/local.yaml")
);
assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb"));
assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote")));
// A remote provider is not copied, but it must still be declared: the Service decides
// whether the Core's download cache is reusable by comparing the url that produced it,
// and an undeclared provider is one it can only ever discard.
assert_eq!(
bundle
.remote_providers
.iter()
.map(|provider| (provider.destination.as_str(), provider.url.as_str()))
.collect::<Vec<_>>(),
[("providers/remote.yaml", "https://example.com/p.yaml")],
"the declaration must pair the rewritten destination with the url, not the original path"
);
assert!(
!bundle
.assets
.iter()
.any(|asset| asset.destination.contains("geoip.dat"))
);
std::fs::remove_dir_all(root)?;
Ok(())
}
#[tokio::test]
async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> {
// The Service folds an identical repeat, so refusing it here would block a configuration
// mihomo itself accepts — and this is also where a Service-mode *start* gets its bundle,
// so the refusal would stop the core coming up at all.
let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
let config = root.join("config.yaml");
std::fs::write(
&config,
"rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n",
)?;
let core = root.join("mihomo");
std::fs::write(&core, b"core")?;
let bundle = collect_runtime_bundle(&config, &core).await?;
assert_eq!(bundle.remote_providers.len(), 1, "one file, declared twice");
assert_eq!(bundle.remote_providers[0].destination, "rules/x.yaml");
std::fs::remove_dir_all(root)?;
Ok(())
}
#[tokio::test]
async fn two_sources_for_one_remote_file_are_refused() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-bundle-conflict-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
let config = root.join("config.yaml");
std::fs::write(
&config,
"rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://two.example/x.yaml\n path: ./rules/x.yaml\n",
)?;
let core = root.join("mihomo");
std::fs::write(&core, b"core")?;
let Err(error) = collect_runtime_bundle(&config, &core).await else {
anyhow::bail!("two sources cannot own one file");
};
assert!(error.to_string().contains("two different sources"), "{error}");
std::fs::remove_dir_all(root)?;
Ok(())
}
#[tokio::test]
async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> {
// Measured against mihomo v1.19.26: it accepts a `http` provider with no url, and a
// non-http provider whose path does not exist. Neither may stop the rest being materialised.
let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root)?;
let config = root.join("config.yaml");
std::fs::write(
&config,
"rule-providers:\n nourl:\n type: http\n path: ./rules/nourl.yaml\n missing:\n type: inline\n url: https://one.example/i.yaml\n path: ./rules/missing.yaml\n good:\n type: http\n url: https://one.example/g.yaml\n path: ./rules/g.yaml\n",
)?;
let core = root.join("mihomo");
std::fs::write(&core, b"core")?;
let bundle = collect_runtime_bundle(&config, &core).await?;
assert_eq!(
bundle.remote_providers.len(),
1,
"only the provider that could be classified is declared"
);
assert_eq!(bundle.remote_providers[0].destination, "rules/g.yaml");
let reserialized: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?;
assert_eq!(
reserialized["rule-providers"]["nourl"]["path"].as_str(),
Some("./rules/nourl.yaml"),
"an unclassifiable provider keeps the path the core was given"
);
std::fs::remove_dir_all(root)?;
Ok(())
}
#[tokio::test]
async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id()));
std::fs::create_dir_all(&root)?;
let config = root.join("config.yaml");
let outside = root
.parent()
.ok_or_else(|| anyhow::anyhow!("test root has no parent"))?
.join(format!("outside-{}.yaml", std::process::id()));
std::fs::write(
&config,
format!(
"proxy-providers:\n remote:\n type: http\n url: https://example.com/p.yaml\n path: {}\n",
outside.display()
),
)?;
let core = root.join("mihomo");
std::fs::write(&core, b"core")?;
let Err(error) = collect_runtime_bundle(&config, &core).await else {
anyhow::bail!("outside provider path must be rejected");
};
assert!(error.to_string().contains("outside the config root"));
std::fs::remove_dir_all(root)?;
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+40 -17
View File
@@ -1,6 +1,7 @@
use crate::{
config::{Config, IVerge},
config::{Config, MixedPort},
singleton,
utils::server,
};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
@@ -33,8 +34,9 @@ const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApply
}
}
pub struct Sysopt {
pub(crate) struct Sysopt {
update_lock: TokioMutex<()>,
guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>,
@@ -44,6 +46,7 @@ impl Default for Sysopt {
fn default() -> Self {
Self {
update_lock: TokioMutex::new(()),
guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false),
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
@@ -84,17 +87,35 @@ impl Sysopt {
Arc::clone(&self.guard)
}
pub async fn refresh_guard(&self) {
async fn stop_proxy_guard_locked(&self) {
loop {
let state = self.access_guard().read().get_state();
if state.is_pendding() {
tokio::task::yield_now().await;
continue;
}
self.access_guard().write().stop();
return;
}
}
pub(super) async fn stop_proxy_guard(&self) {
let _operation = self.guard_operation_lock.lock().await;
self.stop_proxy_guard_locked().await;
}
pub(super) async fn refresh_guard(&self) {
logging!(info, Type::Core, "Refreshing system proxy guard...");
let verge = Config::verge().await.latest_arc();
let _operation = self.guard_operation_lock.lock().await;
if !verge.enable_system_proxy.unwrap_or_default() {
logging!(info, Type::Core, "System proxy is disabled.");
self.access_guard().write().stop();
self.stop_proxy_guard_locked().await;
return;
}
if !verge.enable_proxy_guard.unwrap_or_default() {
logging!(info, Type::Core, "System proxy guard is disabled.");
self.access_guard().write().stop();
self.stop_proxy_guard_locked().await;
return;
}
logging!(
@@ -114,26 +135,27 @@ impl Sysopt {
let guard = self.access_guard();
guard.write().start();
}
while self.access_guard().read().get_state().is_pendding() {
tokio::task::yield_now().await;
}
}
/// Wait for any in-progress `update_sysproxy` to finish, so that a
/// subsequent read of OS-level sysproxy state sees a fully applied
/// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition).
pub async fn wait_idle(&self) {
pub(crate) async fn wait_idle(&self) {
let _ = self.update_lock.lock().await;
}
/// init the sysproxy
pub async fn update_sysproxy(&self) -> Result<()> {
pub(super) async fn update_sysproxy(&self) -> Result<()> {
let _lock = self.update_lock.lock().await;
let verge = Config::verge().await.latest_arc();
let port = match verge.verge_mixed_port {
Some(port) => port,
None => Config::clash().await.latest_arc().get_mixed_port(),
};
let pac_port = IVerge::get_singleton_port();
// Configured, not live: this runs while the Core is being started or restarted, and
// asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await;
@@ -178,6 +200,7 @@ impl Sysopt {
(sys.clone(), auto.clone(), guard_type)
};
let _guard_operation = self.guard_operation_lock.lock().await;
self.access_guard().write().set_guard_type(guard_type);
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
@@ -197,7 +220,7 @@ impl Sysopt {
}
/// reset the sysproxy
pub async fn reset_sysproxy(&self) -> Result<()> {
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self
.reset_sysproxy
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
@@ -208,9 +231,9 @@ impl Sysopt {
defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst);
}
// close proxy guard
self.access_guard().write().set_guard_type(GuardType::None);
let _lock = self.update_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await;
self.stop_proxy_guard_locked().await;
// 直接关闭所有代理
let (sys, auto) = {
+127 -26
View File
@@ -36,6 +36,24 @@ struct TaskState {
running: bool,
}
async fn run_timer_profile_update_transition<Started, Update, UpdateFuture, Finished, Completed, Output>(
update_started: Started,
update_profile: Update,
update_finished: Finished,
update_completed: Completed,
) where
Started: FnOnce(),
Update: FnOnce() -> UpdateFuture,
UpdateFuture: std::future::Future<Output = Output>,
Finished: FnOnce(Output),
Completed: FnOnce(),
{
update_started();
let result = update_profile().await;
update_finished(result);
update_completed();
}
impl TaskState {
const fn new(key: Key, interval_minutes: u64) -> Self {
Self {
@@ -386,36 +404,35 @@ impl Timer {
let task_start = std::time::Instant::now();
logging!(debug, Type::Timer, "Running timer task for profile: {}", uid);
match tokio::time::timeout(std::time::Duration::from_secs(40), async {
Self::emit_update_event(uid, true);
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
logging!(
debug,
Type::Timer,
"Profile {} is current active profile: {}",
uid,
is_current
);
feat::update_profile(uid, None, is_current, false, false).await
})
.await
{
Ok(Ok(_)) => {
run_timer_profile_update_transition(
|| Self::emit_update_event(uid, true),
|| async {
let is_current = Config::profiles().await.latest_arc().current.as_ref() == Some(uid);
logging!(
info,
debug,
Type::Timer,
"Timer task completed for uid: {} (took {}ms)",
"Profile {} is current active profile: {}",
uid,
task_start.elapsed().as_millis()
is_current
);
}
Ok(Err(e)) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
Err(_) => logging_error!(Type::Timer, "Timer task timed out for uid: {}", uid),
}
Self::emit_update_event(uid, false);
feat::update_profile(uid, None, is_current, false, false).await
},
|result| match result {
Ok(_) => {
logging!(
info,
Type::Timer,
"Timer task completed for uid: {} (took {}ms)",
uid,
task_start.elapsed().as_millis()
);
}
Err(e) => logging_error!(Type::Timer, "Failed to update profile uid {}: {}", uid, e),
},
|| Self::emit_update_event(uid, false),
)
.await;
}
async fn wait_until_resolve_done(max_wait: Duration) {
@@ -431,8 +448,31 @@ impl Timer {
#[cfg(test)]
mod tests {
use super::Timer;
use super::{Timer, run_timer_profile_update_transition};
use crate::config::{PrfItem, PrfOption};
use parking_lot::Mutex;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
use tokio::sync::Barrier;
struct CancellationProbe {
cancelled: Arc<AtomicBool>,
completed: Arc<AtomicBool>,
}
impl Drop for CancellationProbe {
fn drop(&mut self) {
if !self.completed.load(Ordering::Acquire) {
self.cancelled.store(true, Ordering::Release);
}
}
}
fn remote_profile(uid: &str, allow_auto_update: Option<bool>, update_interval: Option<u64>) -> PrfItem {
PrfItem {
@@ -463,4 +503,65 @@ mod tests {
assert_eq!(map.get("enabled"), Some(&30));
assert_eq!(map.get("missing-flag"), Some(&30));
}
#[tokio::test(start_paused = true)]
async fn timer_profile_update_runs_past_former_deadline_and_pairs_events() -> anyhow::Result<()> {
let calls = Arc::new(Mutex::new(Vec::new()));
let update_started = Arc::new(Barrier::new(2));
let release_update = Arc::new(Barrier::new(2));
let update_cancelled = Arc::new(AtomicBool::new(false));
let update_completed = Arc::new(AtomicBool::new(false));
let mut update = Box::pin(run_timer_profile_update_transition(
{
let calls = Arc::clone(&calls);
move || calls.lock().push("update-started")
},
{
let update_started = Arc::clone(&update_started);
let release_update = Arc::clone(&release_update);
let update_cancelled = Arc::clone(&update_cancelled);
let update_completed = Arc::clone(&update_completed);
move || async move {
let _probe = CancellationProbe {
cancelled: update_cancelled,
completed: Arc::clone(&update_completed),
};
update_started.wait().await;
release_update.wait().await;
update_completed.store(true, Ordering::Release);
Ok::<(), anyhow::Error>(())
}
},
{
let calls = Arc::clone(&calls);
move |result: anyhow::Result<()>| {
assert!(result.is_ok());
calls.lock().push("terminal-result");
}
},
{
let calls = Arc::clone(&calls);
move || calls.lock().push("update-completed")
},
));
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
update_started.wait().await;
tokio::time::advance(Duration::from_secs(41)).await;
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
assert!(!update_cancelled.load(Ordering::Acquire));
assert_eq!(&*calls.lock(), &["update-started"]);
release_update.wait().await;
update.await;
assert!(update_completed.load(Ordering::Acquire));
assert!(!update_cancelled.load(Ordering::Acquire));
assert_eq!(
&*calls.lock(),
&["update-started", "terminal-result", "update-completed"]
);
Ok(())
}
}
+7 -12
View File
@@ -1,5 +1,4 @@
use crate::config::{IProfilePreview, IVerge};
use crate::core::service;
use crate::core::tray::menu_def::TrayAction;
use crate::module::lightweight;
use crate::process::AsyncHandler;
@@ -15,7 +14,6 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs;
@@ -208,8 +206,7 @@ impl Tray {
let verge = Config::verge().await.latest_arc();
let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false);
let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false);
let tun_mode_available =
is_current_app_handle_admin(app_handle) || service::is_service_available().await.is_ok();
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
let mode = {
Config::clash()
.await
@@ -612,12 +609,10 @@ async fn create_tray_menu(
// TODO: should update tray menu again when it was timeout error
let (proxy_nodes_data, runtime_proxy_groups_order) = if options.include_proxy_groups {
let proxy_nodes_data = tokio::time::timeout(
Duration::from_millis(1000),
handle::Handle::mihomo().await.get_proxies(),
)
.await
.map_or(None, |res| res.ok());
let proxy_nodes_data =
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies())
.await
.map_or(None, |res| res.ok());
let runtime_proxy_groups_order = cmd::get_runtime_config()
.await
@@ -976,7 +971,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
feat::toggle_tun_mode(None).await;
}
MenuIds::CLOSE_ALL_CONNECTIONS => {
if let Err(err) = handle::Handle::mihomo().await.close_all_connections().await {
if let Err(err) = handle::Handle::mihomo().close_all_connections().await {
logging!(error, Type::Tray, "Failed to close all connections from tray: {err}");
}
}
@@ -994,7 +989,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
let _ = help::open_app_latest_log();
}
MenuIds::CORE_LOG => {
let _ = help::open_core_latest_log();
let _ = help::open_core_latest_log().await;
}
MenuIds::RESTART_CLASH => feat::restart_clash_core().await,
MenuIds::RESTART_APP => feat::restart_app().await,
+6 -6
View File
@@ -6,7 +6,7 @@ use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri_plugin_mihomo::models::ConnectionId;
use tauri_plugin_mihomo::models::WsConnectionId;
/// 托盘速率流异常后的重连间隔。
const TRAY_SPEED_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -19,7 +19,7 @@ const TRAY_SPEED_STALE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub struct TraySpeedController {
speed_task: Arc<Mutex<Option<JoinHandle<()>>>>,
speed_connection_id: Arc<Mutex<Option<ConnectionId>>>,
speed_connection_id: Arc<Mutex<Option<WsConnectionId>>>,
}
impl Default for TraySpeedController {
@@ -162,17 +162,17 @@ impl TraySpeedController {
}
fn set_speed_connection_id(
speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>,
connection_id: Option<ConnectionId>,
speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>,
connection_id: Option<WsConnectionId>,
) {
*speed_connection_id.lock() = connection_id;
}
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) -> Option<ConnectionId> {
fn take_speed_connection_id(speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>) -> Option<WsConnectionId> {
speed_connection_id.lock().take()
}
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<ConnectionId>>>) {
async fn disconnect_speed_connection(speed_connection_id: &Arc<Mutex<Option<WsConnectionId>>>) {
if let Some(connection_id) = Self::take_speed_connection_id(speed_connection_id) {
connections_stream::disconnect_connection(connection_id).await;
}
+29 -1
View File
@@ -127,6 +127,21 @@ fn version_lte(a: &str, b: &str) -> bool {
true // equal
}
/// Map the app's resolved UI language code to an NSIS installer language ID.
///
/// The Windows installer only ships SimpChinese, English and Russian
/// (`tauri.windows.conf.json` `nsis.languages`). Languages without a matching
/// NSIS translation fall back to English; Traditional Chinese ("zhtw") is
/// mapped to SimpChinese so Chinese users don't get an English installer.
#[cfg(target_os = "windows")]
fn nsis_language_id(app_language: &str) -> &'static str {
match app_language {
"zh" | "zhtw" => "2052", // SimpChinese
"ru" => "1049", // Russian
_ => "1033", // English
}
}
// ─── Startup Install & Cache Management ─────────────────────────────────────
impl SilentUpdater {
@@ -186,7 +201,20 @@ impl SilentUpdater {
// Need a fresh Update object from the server to call install().
// This is a lightweight HTTP request (< 1s), not a re-download.
let update = match app_handle.updater() {
//
// The updater is built with the app's current language forwarded as
// `/LANG=<NSIS-lang-id>`. On Windows the NSIS installer parses this in
// `.onInit` (see `packages/windows/installer.nsi`) and sets `$LANGUAGE`
// directly, skipping its language-selection dialog so the update starts
// without prompting. Other platforms don't run an installer.
let updater_builder = app_handle.updater_builder();
#[cfg(target_os = "windows")]
let updater_builder = {
let verge_lang = Config::verge().await.latest_arc().language.clone();
let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref()));
updater_builder.installer_arg(format!("/LANG={lang_id}"))
};
let update = match updater_builder.build() {
Ok(updater) => match updater.check().await {
Ok(Some(u)) => u,
Ok(None) => {

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