mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 05:40:47 +08:00
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
This commit is contained in:
@@ -78,6 +78,10 @@ jobs:
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm test
|
||||
|
||||
- name: Run Knip
|
||||
if: steps.check_frontend.outputs.frontend == 'true'
|
||||
run: pnpm knip:check
|
||||
|
||||
Generated
+3
-3
@@ -1212,8 +1212,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clash_verge_service_ipc"
|
||||
version = "2.5.3"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.5.3#fd229aafae36b0bba547af56167b4ee26cc73a65"
|
||||
version = "2.6.0"
|
||||
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?branch=feat%2Fstage-runtime#fe6a771a35488eb3e8f3a26c901d3a03b5e2953c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"compact_str",
|
||||
@@ -3808,7 +3808,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "kode-bridge"
|
||||
version = "0.4.2"
|
||||
source = "git+https://github.com/clash-verge-rev/kode-bridge.git?branch=dev#fbd9888431cff8fba30934c9fbe1a553fce1ef1b"
|
||||
source = "git+https://github.com/clash-verge-rev/kode-bridge.git?branch=dev#9dc3ee4689e46252600b9c1b12d20961fce113b4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
@@ -145,3 +145,73 @@ impl<T: Clone> Draft<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft layer that can be 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 {
|
||||
fn apply_draft(&self);
|
||||
fn discard_draft(&self);
|
||||
}
|
||||
|
||||
impl<T: Clone + Send + Sync> DraftLayer for Draft<T> {
|
||||
#[inline]
|
||||
fn apply_draft(&self) {
|
||||
self.apply();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn discard_draft(&self) {
|
||||
self.discard();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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> {
|
||||
/// Begin a transaction over `layers`, which are committed in the order given.
|
||||
pub fn new(layers: Vec<&'a dyn DraftLayer>) -> Self {
|
||||
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) {
|
||||
if self.committed {
|
||||
return;
|
||||
}
|
||||
for layer in &self.layers {
|
||||
layer.discard_draft();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,3 +303,78 @@ mod tests {
|
||||
assert_eq!(latest.enable_tun_mode, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
mod transactions {
|
||||
use clash_verge_draft::{Draft, DraftTransaction};
|
||||
|
||||
#[test]
|
||||
fn committing_applies_every_layer() {
|
||||
let first = Draft::new(1_u8);
|
||||
let second = Draft::new("a".to_owned());
|
||||
|
||||
let transaction = DraftTransaction::new(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 = DraftTransaction::new(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 = DraftTransaction::new(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(DraftTransaction::new(vec![&edited, &untouched]));
|
||||
edited.edit_draft(|value| *value = 2);
|
||||
|
||||
{
|
||||
let _transaction = DraftTransaction::new(vec![&edited, &untouched]);
|
||||
}
|
||||
|
||||
assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too");
|
||||
assert_eq!(**untouched.latest_arc(), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default defineConfig([
|
||||
'eslint.config.ts',
|
||||
`vite.config.mts`,
|
||||
'scripts/*.mjs',
|
||||
'tests/*.ts',
|
||||
'src/polyfills/*.js',
|
||||
'tests/*.mjs',
|
||||
],
|
||||
|
||||
+6
-1
@@ -34,6 +34,8 @@
|
||||
"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"
|
||||
@@ -88,6 +90,7 @@
|
||||
"@eslint-react/eslint-plugin": "^5.8.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.12.4",
|
||||
@@ -115,6 +118,7 @@
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "6.26.0",
|
||||
"lint-staged": "^17.0.5",
|
||||
"sass": "^1.100.0",
|
||||
@@ -123,7 +127,8 @@
|
||||
"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}": [
|
||||
|
||||
Generated
+644
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,8 @@ tauri-plugin-devtools = { version = "2.1.0" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" }
|
||||
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
|
||||
async-trait = "0.1.89"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.5.3", version = "2.5.3", features = [
|
||||
# Tracks a branch until the protocol change it carries is tagged; move this to v2.6.0 then.
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", branch = "feat/stage-runtime", version = "2.6.0", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.2"
|
||||
|
||||
+33
-1
@@ -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,7 +1,7 @@
|
||||
use super::{CmdResult, WithErrorCode as _};
|
||||
use crate::core::{
|
||||
CoreManager,
|
||||
service::{self, SERVICE_MANAGER, ServiceStatus},
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
};
|
||||
|
||||
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
|
||||
@@ -42,11 +42,6 @@ pub async fn repair_service() -> CmdResult {
|
||||
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_service_install_state() -> CmdResult<service::ServiceInstallState> {
|
||||
Ok(SERVICE_MANAGER.install_state().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 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`].
|
||||
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().await.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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod clash;
|
||||
#[allow(clippy::module_inception)]
|
||||
mod config;
|
||||
mod encrypt;
|
||||
mod mixed_port;
|
||||
mod port;
|
||||
mod prfitem;
|
||||
pub mod profiles;
|
||||
@@ -9,7 +10,7 @@ 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;";
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
constants::timing,
|
||||
core::{
|
||||
handle::Handle,
|
||||
listener::ListenerBindScope,
|
||||
listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys},
|
||||
owner_identity::current_owner_credentials,
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
validate::CoreConfigValidator,
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
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;
|
||||
@@ -88,46 +89,29 @@ impl Config {
|
||||
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::new(vec![&clash, &verge, &runtime]);
|
||||
|
||||
clash.edit_draft(|draft| {
|
||||
draft.0.insert("mixed-port".into(), new_port.into());
|
||||
draft.0.insert(MIXED_PORT_KEY.into(), new_port.into());
|
||||
});
|
||||
verge.edit_draft(|draft| {
|
||||
draft.verge_mixed_port = Some(new_port);
|
||||
});
|
||||
|
||||
if let Err(error) = Self::generate().await {
|
||||
clash.discard();
|
||||
verge.discard();
|
||||
runtime.discard();
|
||||
return Err(error).context("failed to materialize runtime configuration with fallback port");
|
||||
}
|
||||
Self::generate()
|
||||
.await
|
||||
.context("failed to materialize runtime configuration with fallback port")?;
|
||||
|
||||
let validation = match CoreConfigValidator::global().validate_config_outcome().await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
clash.discard();
|
||||
verge.discard();
|
||||
runtime.discard();
|
||||
return Err(error).context("failed to validate 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() {
|
||||
clash.discard();
|
||||
verge.discard();
|
||||
runtime.discard();
|
||||
bail!("runtime configuration with fallback port is invalid: {validation}");
|
||||
}
|
||||
|
||||
let snapshots = match capture_config_files().await {
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(error) => {
|
||||
clash.discard();
|
||||
verge.discard();
|
||||
runtime.discard();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let snapshots = capture_config_files().await?;
|
||||
let candidate_clash = clash.latest_arc();
|
||||
let candidate_verge = verge.latest_arc();
|
||||
|
||||
@@ -147,19 +131,18 @@ impl Config {
|
||||
}
|
||||
.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 {
|
||||
clash.discard();
|
||||
verge.discard();
|
||||
runtime.discard();
|
||||
transaction.rollback();
|
||||
return match restore_files(&snapshots).await {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
|
||||
};
|
||||
}
|
||||
|
||||
clash.apply();
|
||||
verge.apply();
|
||||
runtime.apply();
|
||||
transaction.commit();
|
||||
record_fallback(old_port, new_port);
|
||||
Handle::refresh_clash();
|
||||
Handle::refresh_verge();
|
||||
@@ -278,7 +261,9 @@ async fn owned_service_core_uses_port(port: u16) -> bool {
|
||||
|
||||
fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet<u16> {
|
||||
let mut ports = HashSet::new();
|
||||
for key in ["socks-port", "port", "redir-port", "tproxy-port"] {
|
||||
// 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);
|
||||
}
|
||||
@@ -308,6 +293,27 @@ fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option<u16> {
|
||||
})
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -48,6 +48,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,
|
||||
|
||||
@@ -8,8 +8,15 @@ use std::{
|
||||
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", "mixed", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
|
||||
(
|
||||
MIXED_PORT_KEY,
|
||||
"mixed",
|
||||
&[ListenerTransport::Tcp, ListenerTransport::Udp],
|
||||
),
|
||||
("socks-port", "socks", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
|
||||
("port", "http", &[ListenerTransport::Tcp]),
|
||||
("redir-port", "redir", &[ListenerTransport::Tcp]),
|
||||
@@ -20,6 +27,14 @@ const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{CoreManager, RunningMode};
|
||||
use crate::core::service::StageRequest;
|
||||
use crate::{
|
||||
config::{Config, ConfigType, runtime::IRuntime},
|
||||
constants::timing,
|
||||
@@ -9,12 +10,96 @@ 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 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, 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,
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> {
|
||||
use crate::constants::files::RUNTIME_CONFIG;
|
||||
@@ -92,13 +177,15 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
async fn perform_config_update(&self) -> Result<ValidationOutcome> {
|
||||
let runtime = Config::runtime().await;
|
||||
let transaction = DraftTransaction::new(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,75 +200,127 @@ 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::new(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<()> {
|
||||
if matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
let _lifecycle = self.lifecycle_lock.lock().await;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Service) {
|
||||
Config::runtime().await.discard();
|
||||
return Err(anyhow!("core mode changed while applying service configuration"));
|
||||
}
|
||||
let result = self.replace_service_core_with_config(&path).await;
|
||||
|
||||
return match result {
|
||||
Ok(()) => {
|
||||
Config::runtime().await.apply();
|
||||
logging!(info, Type::Core, "Configuration materialized and applied by service");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
Config::runtime().await.discard();
|
||||
Err(error)
|
||||
}
|
||||
};
|
||||
return self.apply_config_by_service(&path).await;
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
Err(err) => {
|
||||
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;
|
||||
}
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,3 +329,105 @@ impl CoreManager {
|
||||
handle::Handle::mihomo().await.reload_config(true, path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ConfigApplication, StageAttempt, StageRequest, plan_config_application};
|
||||
use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome};
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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(),
|
||||
},
|
||||
] {
|
||||
let attempt = StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason: reason.clone() });
|
||||
|
||||
assert_eq!(
|
||||
plan_config_application(&attempt),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::path::Path;
|
||||
use tauri_plugin_clash_verge_sysinfo;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
|
||||
@@ -263,9 +262,7 @@ enum HandoffOutcome {
|
||||
impl CoreManager {
|
||||
async fn rollback_failed_start(&self) {
|
||||
proxy_control::stop_guard().await;
|
||||
crate::utils::server::set_pac_available(false);
|
||||
self.invalidate_core_readiness();
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
}
|
||||
|
||||
pub async fn start_core(&self) -> Result<()> {
|
||||
@@ -360,7 +357,6 @@ impl CoreManager {
|
||||
self.stop_sidecar_after_proxy_clear().await?;
|
||||
}
|
||||
self.rollback_failed_start().await;
|
||||
self.after_core_process();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -370,7 +366,7 @@ impl CoreManager {
|
||||
|
||||
/// Replaces a Service-owned core while the caller holds `lifecycle_lock`.
|
||||
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
|
||||
let result = run_service_config_replacement_transition(
|
||||
run_service_config_replacement_transition(
|
||||
cfg!(target_os = "macos"),
|
||||
proxy_control::stop_guard,
|
||||
proxy_control::clear,
|
||||
@@ -382,9 +378,7 @@ impl CoreManager {
|
||||
},
|
||||
|| self.apply_proxy_after_start(),
|
||||
)
|
||||
.await;
|
||||
self.after_core_process();
|
||||
result
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_proxy_after_start(&self) -> Result<()> {
|
||||
@@ -439,16 +433,11 @@ impl CoreManager {
|
||||
let startup = self.prepare_startup().await;
|
||||
if matches!(startup, StartupDecision::Wait) {
|
||||
self.rollback_failed_start().await;
|
||||
self.after_core_process();
|
||||
return Ok(());
|
||||
}
|
||||
defer! {
|
||||
self.after_core_process();
|
||||
}
|
||||
|
||||
// 等待服务期间可能进入退出;未真正启动时回滚状态。
|
||||
if Handle::global().is_exiting() {
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -496,10 +485,6 @@ impl CoreManager {
|
||||
/// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。
|
||||
async fn stop_core_unprepared_inner(&self) -> Result<()> {
|
||||
CLASH_LOGGER.clear_logs().await;
|
||||
defer! {
|
||||
self.after_core_process();
|
||||
}
|
||||
|
||||
match *self.get_running_mode() {
|
||||
RunningMode::Service => self.stop_core_by_service().await,
|
||||
RunningMode::Sidecar => {
|
||||
@@ -560,15 +545,9 @@ impl CoreManager {
|
||||
startup_decision(&SERVICE_MANAGER.current().await, service_required)
|
||||
}
|
||||
|
||||
pub(in crate::core) fn after_core_process(&self) {
|
||||
let app_handle = Handle::app_handle();
|
||||
tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, self.get_running_mode().to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn wait_for_service_if_needed(&self) {
|
||||
use crate::{config::Config, constants::timing};
|
||||
use backon::{ConstantBuilder, Retryable as _};
|
||||
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
let service_ready = matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready);
|
||||
@@ -585,22 +564,15 @@ impl CoreManager {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_times = timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis();
|
||||
let backoff = ConstantBuilder::default()
|
||||
.with_delay(timing::SERVICE_WAIT_INTERVAL)
|
||||
.with_max_times(max_times as usize);
|
||||
if crate::core::runstate::RUN_STATE.state().service_usable() {
|
||||
return;
|
||||
}
|
||||
|
||||
let wait_for_ready = (|| async {
|
||||
if SERVICE_MANAGER.is_ready_cached() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Probe the service directly. A transient transport failure does not overwrite
|
||||
// the last confirmed install state and is retried within the startup deadline.
|
||||
SERVICE_MANAGER.confirm_ready().await
|
||||
})
|
||||
.retry(backoff);
|
||||
let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait_for_ready).await;
|
||||
// Failing to become ready is not fatal here: the caller falls back to Sidecar, and a
|
||||
// transient transport failure must not overwrite the last confirmed install state.
|
||||
let attempts = (timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis()) as usize;
|
||||
let wait = crate::core::runstate::RUN_STATE.await_ready(attempts, timing::SERVICE_WAIT_INTERVAL);
|
||||
let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait).await;
|
||||
}
|
||||
|
||||
/// 在窗口内等待服务就绪,再从 sidecar 交接到 service
|
||||
@@ -668,7 +640,7 @@ impl CoreManager {
|
||||
if SERVICE_MANAGER.confirm_ready().await.is_err() {
|
||||
return false;
|
||||
}
|
||||
SERVICE_MANAGER.is_ready_cached()
|
||||
crate::core::runstate::RUN_STATE.state().service_usable()
|
||||
}
|
||||
|
||||
/// 服务就绪后停止 sidecar,再以 service 重启内核
|
||||
@@ -1135,9 +1107,9 @@ mod tests {
|
||||
async fn service_config_replacement_is_controlled_and_generation_safe_on_every_platform() {
|
||||
for is_macos in [false, true] {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let manager = CoreManager::default();
|
||||
let manager = CoreManager::isolated();
|
||||
let old_readiness_generation = manager.mark_core_ready();
|
||||
manager.set_running_mode(RunningMode::Service);
|
||||
manager.core_started(RunningMode::Service);
|
||||
let owner_monitor_generation = AtomicU64::new(1);
|
||||
let old_owner_monitor_generation = owner_monitor_generation.load(Ordering::Acquire);
|
||||
|
||||
@@ -1151,14 +1123,14 @@ mod tests {
|
||||
|| {
|
||||
calls.lock().push("helper-stop");
|
||||
owner_monitor_generation.fetch_add(1, Ordering::AcqRel);
|
||||
manager.set_running_mode(RunningMode::NotRunning);
|
||||
manager.core_stopped();
|
||||
future::ready(Ok(()))
|
||||
},
|
||||
|| {
|
||||
calls.lock().push("service-start");
|
||||
owner_monitor_generation.fetch_add(1, Ordering::AcqRel);
|
||||
manager.mark_core_ready();
|
||||
manager.set_running_mode(RunningMode::Service);
|
||||
manager.core_started(RunningMode::Service);
|
||||
if owner_monitor_generation
|
||||
.compare_exchange(
|
||||
old_owner_monitor_generation,
|
||||
@@ -1304,12 +1276,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_start_rolls_back_even_from_service_mode() {
|
||||
let manager = CoreManager::default();
|
||||
manager.set_running_mode(RunningMode::Service);
|
||||
let manager = CoreManager::isolated();
|
||||
manager.core_started(RunningMode::Service);
|
||||
manager.rollback_failed_start().await;
|
||||
assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning);
|
||||
|
||||
manager.set_running_mode(RunningMode::Sidecar);
|
||||
manager.core_started(RunningMode::Sidecar);
|
||||
manager.rollback_failed_start().await;
|
||||
assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{
|
||||
};
|
||||
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;
|
||||
@@ -72,6 +73,11 @@ 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")]
|
||||
@@ -86,24 +92,19 @@ pub struct CoreManager {
|
||||
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")]
|
||||
@@ -122,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> {
|
||||
@@ -142,12 +152,26 @@ impl CoreManager {
|
||||
self.last_update.load_full()
|
||||
}
|
||||
|
||||
pub fn set_running_mode(&self, mode: RunningMode) {
|
||||
if matches!(mode, RunningMode::NotRunning) {
|
||||
self.invalidate_core_readiness();
|
||||
}
|
||||
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.
|
||||
pub fn core_starting(&self) {
|
||||
self.run_state.core_starting();
|
||||
}
|
||||
|
||||
pub fn set_running_child_sidecar(&self, child: CommandChild) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
config::Config,
|
||||
core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service},
|
||||
logging,
|
||||
utils::{dirs, server},
|
||||
utils::dirs,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use clash_verge_logging::Type;
|
||||
@@ -75,8 +75,7 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in sidecar mode");
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
|
||||
let sidecar_ipc = dirs::sidecar_ipc_path()?;
|
||||
handle::Handle::app_handle()
|
||||
@@ -149,9 +148,7 @@ impl CoreManager {
|
||||
.await;
|
||||
if let Err(readiness_error) = readiness {
|
||||
proxy_control::stop_guard().await;
|
||||
self.invalidate_core_readiness();
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
server::set_pac_available(false);
|
||||
self.core_stopped();
|
||||
return match child.kill() {
|
||||
Ok(()) => Err(readiness_error),
|
||||
Err(kill_error) => Err(anyhow::anyhow!(
|
||||
@@ -164,8 +161,7 @@ impl CoreManager {
|
||||
self.set_job_handle(Some(job));
|
||||
self.set_running_child_sidecar(child);
|
||||
let core_readiness_generation = self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Sidecar);
|
||||
server::set_pac_available(true);
|
||||
self.core_started(RunningMode::Sidecar);
|
||||
|
||||
AsyncHandler::spawn(move || async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
@@ -204,8 +200,7 @@ impl CoreManager {
|
||||
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
|
||||
logging!(info, Type::Core, "Stopping sidecar");
|
||||
defer! {
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
}
|
||||
if let Some(child) = self.take_child_sidecar() {
|
||||
let pid = child.pid();
|
||||
@@ -236,7 +231,7 @@ impl CoreManager {
|
||||
|
||||
pub(super) async fn start_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Starting core in service mode");
|
||||
server::set_pac_available(false);
|
||||
self.core_starting();
|
||||
let service_ipc = dirs::ipc_path()?;
|
||||
let config_file = Config::generate_file(crate::config::ConfigType::Run).await?;
|
||||
handle::Handle::app_handle()
|
||||
@@ -258,7 +253,7 @@ impl CoreManager {
|
||||
match service::run_core_by_service(config_file).await {
|
||||
Ok(()) => {
|
||||
self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
self.core_started(RunningMode::Service);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -282,7 +277,7 @@ impl CoreManager {
|
||||
{
|
||||
service::run_core_by_service(config_file).await?;
|
||||
self.mark_core_ready();
|
||||
self.set_running_mode(RunningMode::Service);
|
||||
self.core_started(RunningMode::Service);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -290,8 +285,7 @@ impl CoreManager {
|
||||
pub(super) async fn stop_core_by_service(&self) -> Result<()> {
|
||||
logging!(info, Type::Core, "Stopping service");
|
||||
service::stop_core_by_service().await?;
|
||||
server::set_pac_available(false);
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.core_stopped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -305,10 +299,7 @@ impl CoreManager {
|
||||
#[cfg(target_os = "windows")]
|
||||
self.set_job_handle(None);
|
||||
proxy_control::stop_guard().await;
|
||||
server::set_pac_available(false);
|
||||
self.invalidate_core_readiness();
|
||||
self.set_running_mode(RunningMode::NotRunning);
|
||||
self.after_core_process();
|
||||
self.core_stopped();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,9 +365,9 @@ mod readiness_tests {
|
||||
|
||||
#[test]
|
||||
fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() {
|
||||
let manager = CoreManager::default();
|
||||
let manager = CoreManager::isolated();
|
||||
manager.mark_core_ready();
|
||||
manager.set_running_mode(RunningMode::Service);
|
||||
manager.core_started(RunningMode::Service);
|
||||
|
||||
manager.invalidate_core_readiness();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
config::{Config, IVerge, MixedPort},
|
||||
core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt},
|
||||
process::AsyncHandler,
|
||||
utils::server,
|
||||
@@ -172,10 +172,8 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
|
||||
if verge.proxy_auto_config.unwrap_or_default() {
|
||||
return service_proxy_config(verge, 0, server::embedded_server_port()?);
|
||||
}
|
||||
let mixed_port = match verge.verge_mixed_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.latest_arc().get_mixed_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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! 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::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.
|
||||
fn trusted_install_evidence(&self) -> Result<bool>;
|
||||
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
|
||||
fn trusted_install_evidence(&self) -> Result<bool> {
|
||||
crate::core::service::trusted_service_evidence()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! 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. 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.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_always_needs_an_answer() {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Run State — the single answer to "how is the Core running, and what backs it".
|
||||
//!
|
||||
//! This module owns Service Health, Running Mode, Pending Action and the privileged-operation
|
||||
//! lock. It deliberately does **not** own starting, stopping or restarting the Core — those
|
||||
//! stay in `CoreManager`, which reports transitions in and reads snapshots back. Merging the
|
||||
//! two would leave no point at which the state is consistent, since a snapshot taken
|
||||
//! mid-restart would describe a transition rather than a state.
|
||||
//!
|
||||
//! Reads come in three flavours that differ only in *freshness* — [`RunStateStore::state`]
|
||||
//! (cached), [`RunStateStore::settled`] (waits for any in-flight operation) and
|
||||
//! [`RunStateStore::probe`] (forces live IPC). What the answer *means* is decided by methods
|
||||
//! on [`RunState`], so no caller writes its own availability formula.
|
||||
|
||||
mod env;
|
||||
mod health;
|
||||
mod owner;
|
||||
mod probe;
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use arc_swap::ArcSwap;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
#[cfg(test)]
|
||||
pub use env::FakeEnv;
|
||||
pub use env::{RealEnv, RunStateEnv};
|
||||
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
|
||||
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
|
||||
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
|
||||
|
||||
use crate::core::manager::RunningMode;
|
||||
use health::StoredService;
|
||||
use probe::{CurrentServiceProbe, classify_service_health, probe_outcome};
|
||||
|
||||
/// The process-wide Run State, observing the real machine.
|
||||
pub static RUN_STATE: Lazy<RunStateStore<RealEnv>> = Lazy::new(|| RunStateStore::new(RealEnv));
|
||||
|
||||
/// Why a wait for Service readiness ended without a ready Service.
|
||||
///
|
||||
/// The two are not interchangeable: an unreachable Service told us nothing, so the caller
|
||||
/// decides whether silence means unavailable; a rejected one already updated health and must
|
||||
/// not have that verdict overwritten with a vaguer one.
|
||||
#[derive(Debug)]
|
||||
pub enum ReadyWaitError {
|
||||
/// No readable reply within the attempts budget.
|
||||
Unreachable(anyhow::Error),
|
||||
/// The Service answered and we rejected the answer; health records why.
|
||||
Rejected(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReadyWaitError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unreachable(error) | Self::Rejected(error) => write!(formatter, "{error:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The service-owned state together with the counter that versions it.
|
||||
///
|
||||
/// They share one lock so a reader can never see a change without its version bump.
|
||||
#[derive(Debug, Default)]
|
||||
struct VersionedService {
|
||||
service: StoredService,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl VersionedService {
|
||||
const fn bump(&mut self) {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the Run State and the effects needed to keep it current.
|
||||
#[derive(Debug)]
|
||||
pub struct RunStateStore<E: RunStateEnv> {
|
||||
env: E,
|
||||
service: Mutex<VersionedService>,
|
||||
mode: ArcSwap<RunningMode>,
|
||||
operation_running: AtomicBool,
|
||||
operation_done: Notify,
|
||||
}
|
||||
|
||||
impl<E: RunStateEnv> RunStateStore<E> {
|
||||
pub fn new(env: E) -> Self {
|
||||
Self {
|
||||
env,
|
||||
service: Mutex::new(VersionedService::default()),
|
||||
mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)),
|
||||
operation_running: AtomicBool::new(false),
|
||||
operation_done: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── reads: freshness only ───────────────────────────
|
||||
|
||||
/// The last known Run State, without waiting and without probing.
|
||||
///
|
||||
/// May be stale while a privileged operation is in flight — the snapshot says so via
|
||||
/// [`RunState::op_in_flight`], and [`RunState::service_usable`] accounts for it.
|
||||
pub fn state(&self) -> RunState {
|
||||
let service = self.service.lock().service.clone();
|
||||
self.snapshot(service)
|
||||
}
|
||||
|
||||
/// The Run State once any in-flight privileged operation has finished.
|
||||
pub async fn settled(&self) -> RunState {
|
||||
loop {
|
||||
let notified = self.operation_done.notified();
|
||||
tokio::pin!(notified);
|
||||
// Register as a waiter *before* checking the flag. `notify_waiters` wakes only
|
||||
// those already registered and leaves no permit behind, so without this an
|
||||
// operation finishing between the check and the await is a wakeup lost for good
|
||||
// — and this future would then wait for some unrelated later operation, or
|
||||
// forever.
|
||||
notified.as_mut().enable();
|
||||
|
||||
if !self.operation_running.load(Ordering::Acquire) {
|
||||
return self.state();
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the Service now, record what came back, and return the fresh Run State.
|
||||
///
|
||||
/// A *transport* failure is not an observation: the Service may simply be restarting, so
|
||||
/// the last confirmed health survives and the caller decides whether to retry. Only a
|
||||
/// reply we could read and reject updates health.
|
||||
pub async fn probe(&self) -> Result<RunState> {
|
||||
let reply = self
|
||||
.env
|
||||
.probe_service_version()
|
||||
.await
|
||||
.context("service readiness probe failed")?;
|
||||
self.record_reply(&reply)
|
||||
}
|
||||
|
||||
/// Probe until the Service answers, giving it `attempts` tries `interval` apart.
|
||||
///
|
||||
/// Only an unreachable Service is worth retrying. A reply we could read is the Service's
|
||||
/// answer — retrying an incompatible version twenty times would just delay telling the
|
||||
/// user to reinstall — so the wait ends on the first readable reply either way.
|
||||
pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result<RunState, ReadyWaitError> {
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts {
|
||||
match self.env.probe_service_version().await {
|
||||
Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
Err(ReadyWaitError::Unreachable(last_error.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("service readiness wait was configured with no attempts")
|
||||
})))
|
||||
}
|
||||
|
||||
/// Record a readable reply as an observation, and report whether it was acceptable.
|
||||
fn record_reply(&self, reply: &ServiceVersionReply) -> Result<RunState> {
|
||||
match classify_service_version_reply(reply) {
|
||||
ServiceVersionCheck::Ready => {
|
||||
self.observe(ServiceHealth::Ready);
|
||||
Ok(self.state())
|
||||
}
|
||||
ServiceVersionCheck::NeedsReinstall(error) => {
|
||||
self.observe(ServiceHealth::VersionMismatch);
|
||||
bail!(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Work out the Service's health from scratch: platform evidence first, then a live probe.
|
||||
///
|
||||
/// Platform evidence that cannot be inspected is *unavailable*, not *absent* — assuming
|
||||
/// "not installed" there would offer the user an install that is already there.
|
||||
pub async fn detect_service_health(&self) -> ServiceHealth {
|
||||
let has_marker = match self.env.trusted_install_evidence() {
|
||||
Ok(exists) => exists,
|
||||
Err(error) => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"failed to inspect trusted service evidence: {error:#}"
|
||||
);
|
||||
return ServiceHealth::Unavailable(format!("service detection failed: {error:#}"));
|
||||
}
|
||||
};
|
||||
|
||||
if !has_marker {
|
||||
return classify_service_health(CurrentServiceProbe::Missing, false, "");
|
||||
}
|
||||
|
||||
match self.env.probe_service_version().await {
|
||||
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
|
||||
Err(error) => {
|
||||
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
|
||||
classify_service_health(
|
||||
CurrentServiceProbe::Unavailable,
|
||||
has_marker,
|
||||
&format!("service detection failed: {error:#}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── writes ───────────────────────────
|
||||
|
||||
/// Record an observation about the Service, clearing any request it answers.
|
||||
pub fn observe(&self, health: ServiceHealth) {
|
||||
let mut state = self.service.lock();
|
||||
if state.service.health == health && state.service.pending.is_none() && !state.service.sidecar_allowed {
|
||||
return;
|
||||
}
|
||||
state.service.observe(health);
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
}
|
||||
|
||||
/// Record a requested privileged operation without any eligibility check.
|
||||
///
|
||||
/// Used when the request came from an explicit user action that has already been
|
||||
/// authorised; the guarded session transitions below are for automatic decisions.
|
||||
pub fn request_action(&self, action: PendingAction) {
|
||||
let mut state = self.service.lock();
|
||||
state.service.request(action);
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
}
|
||||
|
||||
/// Accept Sidecar for the rest of this app session without any eligibility check.
|
||||
///
|
||||
/// For builds and paths where Sidecar is the decided outcome rather than a fallback.
|
||||
pub fn accept_sidecar(&self) {
|
||||
let mut state = self.service.lock();
|
||||
if state.service.sidecar_allowed && state.service.pending.is_none() {
|
||||
return;
|
||||
}
|
||||
state.service.allow_sidecar();
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
}
|
||||
|
||||
/// Accept Sidecar for the rest of this app session.
|
||||
///
|
||||
/// Rejected while an operation is in flight, or from a state where the Service is
|
||||
/// already usable or is mid-privileged-operation other than an install.
|
||||
pub fn allow_sidecar_for_session(&self) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
if self.operation_running.load(Ordering::Acquire) {
|
||||
bail!("service operation already running");
|
||||
}
|
||||
let permitted = match (state.service.pending, state.service.sidecar_allowed) {
|
||||
(Some(PendingAction::Install), _) => true,
|
||||
(Some(_), _) | (None, true) => false,
|
||||
(None, false) => matches!(
|
||||
state.service.health,
|
||||
ServiceHealth::NotInstalled | ServiceHealth::VersionMismatch | ServiceHealth::Unavailable(_)
|
||||
),
|
||||
};
|
||||
if !permitted {
|
||||
bail!("sidecar cannot be allowed from service state {:?}", state.service);
|
||||
}
|
||||
state.service.allow_sidecar();
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Require the Service to be installed before the Core starts, when it is simply absent.
|
||||
///
|
||||
/// A no-op from any other state, mirroring the rule that an already-requested or
|
||||
/// already-usable Service must not be downgraded to "needs installing".
|
||||
pub fn require_install_for_session(&self) -> Result<()> {
|
||||
let mut state = self.service.lock();
|
||||
if self.operation_running.load(Ordering::Acquire) {
|
||||
bail!("service operation already running");
|
||||
}
|
||||
if state.service.pending.is_none()
|
||||
&& !state.service.sidecar_allowed
|
||||
&& state.service.health == ServiceHealth::NotInstalled
|
||||
{
|
||||
state.service.request(PendingAction::Install);
|
||||
state.bump();
|
||||
}
|
||||
drop(state);
|
||||
self.announce();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carry out a privileged operation and record what it did to the Service.
|
||||
///
|
||||
/// Only an uninstall has an outcome we can record without asking again: it either removed
|
||||
/// the Service or left it in a state we no longer trust. After an install or repair the
|
||||
/// Service has to be asked whether it came back, which is [`Self::await_ready`]'s job.
|
||||
pub fn perform(&self, action: PendingAction) -> Result<()> {
|
||||
let outcome = self.env.run_privileged(action);
|
||||
if !matches!(action, PendingAction::Uninstall) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
self.observe(ServiceHealth::NotInstalled);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
self.observe(ServiceHealth::Unavailable(format!(
|
||||
"Service uninstall failed: {error:#}"
|
||||
)));
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── running mode ───────────────────────────
|
||||
|
||||
/// The Running Mode, shared so hot callers such as the tray do not allocate.
|
||||
pub fn mode_arc(&self) -> Arc<RunningMode> {
|
||||
Arc::clone(&self.mode.load())
|
||||
}
|
||||
|
||||
/// The Core is now running, and serving, in `mode`.
|
||||
pub fn core_started(&self, mode: RunningMode) {
|
||||
self.enter_mode(mode);
|
||||
}
|
||||
|
||||
/// The Core is no longer running.
|
||||
pub fn core_stopped(&self) {
|
||||
self.enter_mode(RunningMode::NotRunning);
|
||||
}
|
||||
|
||||
/// A start attempt is under way: the Core is not serving yet, whatever the mode says.
|
||||
///
|
||||
/// Closes the PAC endpoint without disturbing the Running Mode, so a handover cannot
|
||||
/// hand out a PAC script for a proxy port that is between owners.
|
||||
pub fn core_starting(&self) {
|
||||
self.env.set_pac_available(false);
|
||||
}
|
||||
|
||||
/// Move to `mode` and re-derive everything that follows from it.
|
||||
///
|
||||
/// PAC availability and the outward mirror are derived here and nowhere else, so they
|
||||
/// cannot drift from the Running Mode the way ten hand-paired call sites could.
|
||||
///
|
||||
/// PAC closes *before* the mode changes and opens *after*, so whatever a concurrent
|
||||
/// reader catches mid-transition is the closed state: being refused a PAC script beats
|
||||
/// being handed one that points at a proxy port between owners.
|
||||
fn enter_mode(&self, mode: RunningMode) {
|
||||
let running = !matches!(mode, RunningMode::NotRunning);
|
||||
if !running {
|
||||
self.env.set_pac_available(false);
|
||||
}
|
||||
self.mode.store(Arc::new(mode));
|
||||
if running {
|
||||
self.env.set_pac_available(true);
|
||||
}
|
||||
self.announce();
|
||||
}
|
||||
|
||||
// ─────────────────────────── privileged operations ───────────────────────────
|
||||
|
||||
/// Claim the privileged-operation slot, releasing it when the guard drops.
|
||||
///
|
||||
/// Only one privileged operation may be in flight; [`Self::settled`] waits on this.
|
||||
pub fn begin_operation(&self) -> Result<OperationGuard<'_, E>> {
|
||||
let mut state = self.service.lock();
|
||||
if self.operation_running.swap(true, Ordering::AcqRel) {
|
||||
bail!("service operation already running");
|
||||
}
|
||||
state.bump();
|
||||
drop(state);
|
||||
self.announce();
|
||||
Ok(OperationGuard { store: self })
|
||||
}
|
||||
|
||||
/// Whether a privileged operation is in flight right now.
|
||||
///
|
||||
/// Production code reads this through [`RunState::op_in_flight`]; this accessor exists for
|
||||
/// tests that assert on the slot without taking a snapshot.
|
||||
#[cfg(test)]
|
||||
pub fn operation_in_flight(&self) -> bool {
|
||||
self.operation_running.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// The environment this store observes, so tests can assert on the effects it recorded.
|
||||
#[cfg(test)]
|
||||
pub const fn env(&self) -> &E {
|
||||
&self.env
|
||||
}
|
||||
|
||||
/// Bumped on every state change; lets tests assert that a no-op really was a no-op.
|
||||
#[cfg(test)]
|
||||
pub fn generation_count(&self) -> u64 {
|
||||
self.service.lock().generation
|
||||
}
|
||||
|
||||
/// Tell the outside world the Run State moved.
|
||||
///
|
||||
/// Called after every mutation and never while the state lock is held, so a publisher that
|
||||
/// reads back the state cannot deadlock against the writer that triggered it.
|
||||
fn announce(&self) {
|
||||
self.env.publish(&self.state());
|
||||
}
|
||||
|
||||
fn snapshot(&self, service: StoredService) -> RunState {
|
||||
RunState {
|
||||
health: service.health,
|
||||
pending: service.pending,
|
||||
sidecar_allowed: service.sidecar_allowed,
|
||||
mode: *self.mode.load().as_ref(),
|
||||
is_admin: self.env.is_elevated(),
|
||||
op_in_flight: self.operation_running.load(Ordering::Acquire),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the privileged-operation slot and wakes everything waiting in [`RunStateStore::settled`].
|
||||
#[derive(Debug)]
|
||||
pub struct OperationGuard<'a, E: RunStateEnv> {
|
||||
store: &'a RunStateStore<E>,
|
||||
}
|
||||
|
||||
impl<E: RunStateEnv> Drop for OperationGuard<'_, E> {
|
||||
fn drop(&mut self) {
|
||||
self.store.operation_running.store(false, Ordering::Release);
|
||||
self.store.operation_done.notify_waiters();
|
||||
self.store.announce();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use env::FakeEnv;
|
||||
|
||||
fn with_env(env: FakeEnv) -> RunStateStore<FakeEnv> {
|
||||
RunStateStore::new(env)
|
||||
}
|
||||
|
||||
fn ready_reply() -> ServiceVersionReply {
|
||||
ServiceVersionReply {
|
||||
code: 0,
|
||||
message: "ok".to_owned(),
|
||||
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_store_knows_nothing() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
let state = store.state();
|
||||
|
||||
assert_eq!(state.health, ServiceHealth::Unknown);
|
||||
assert_eq!(state.pending, None);
|
||||
assert!(!state.sidecar_allowed);
|
||||
assert_eq!(state.mode, RunningMode::NotRunning);
|
||||
assert!(!state.is_admin);
|
||||
assert!(!state.op_in_flight);
|
||||
assert!(!state.service_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevation_comes_from_the_environment() {
|
||||
assert!(with_env(FakeEnv::new().elevated()).state().is_admin);
|
||||
assert!(!with_env(FakeEnv::new()).state().is_admin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_identical_observations_do_not_bump_the_generation() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let generation = store.generation_count();
|
||||
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
assert_eq!(store.generation_count(), generation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observing_clears_a_pending_request() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.request_action(PendingAction::Install);
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
let state = store.state();
|
||||
assert_eq!(state.pending, None);
|
||||
assert!(state.service_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_flight_operation_makes_a_ready_service_unusable() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
assert!(store.state().service_usable());
|
||||
|
||||
let guard = store.begin_operation().expect("slot should be free");
|
||||
assert!(store.state().service_ready());
|
||||
assert!(!store.state().service_usable());
|
||||
|
||||
drop(guard);
|
||||
assert!(store.state().service_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_privileged_operation_runs_at_a_time() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
let guard = store.begin_operation().expect("slot should be free");
|
||||
|
||||
assert!(store.begin_operation().is_err());
|
||||
|
||||
drop(guard);
|
||||
assert!(store.begin_operation().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_may_be_allowed_when_the_service_is_absent_or_broken() {
|
||||
for health in [
|
||||
ServiceHealth::NotInstalled,
|
||||
ServiceHealth::VersionMismatch,
|
||||
ServiceHealth::Unavailable("boom".to_owned()),
|
||||
] {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(health.clone());
|
||||
|
||||
store
|
||||
.allow_sidecar_for_session()
|
||||
.unwrap_or_else(|error| panic!("{health:?} should permit sidecar: {error}"));
|
||||
assert!(store.state().sidecar_allowed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_may_be_allowed_after_an_install_was_requested() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
store.require_install_for_session().expect("install may be required");
|
||||
assert_eq!(store.state().pending, Some(PendingAction::Install));
|
||||
|
||||
store.allow_sidecar_for_session().expect("install may be abandoned");
|
||||
|
||||
let state = store.state();
|
||||
assert!(state.sidecar_allowed);
|
||||
assert_eq!(state.pending, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_is_refused_when_the_service_is_usable_or_unknown() {
|
||||
for health in [ServiceHealth::Unknown, ServiceHealth::Ready] {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(health.clone());
|
||||
|
||||
assert!(
|
||||
store.allow_sidecar_for_session().is_err(),
|
||||
"{health:?} should refuse sidecar"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_is_refused_during_a_privileged_operation() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
let _guard = store.begin_operation().expect("slot should be free");
|
||||
|
||||
assert!(store.allow_sidecar_for_session().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requiring_an_install_only_applies_to_an_absent_service() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
store.require_install_for_session().expect("absent service");
|
||||
assert_eq!(store.state().pending, Some(PendingAction::Install));
|
||||
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
store.require_install_for_session().expect("no-op, not an error");
|
||||
assert_eq!(store.state().pending, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requiring_an_install_is_refused_during_a_privileged_operation() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
let _guard = store.begin_operation().expect("slot should be free");
|
||||
|
||||
assert!(store.require_install_for_session().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settled_waits_for_an_operation_to_finish() {
|
||||
let store = Arc::new(with_env(FakeEnv::new()));
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
let guard = store.begin_operation().expect("slot should be free");
|
||||
|
||||
let waiter = {
|
||||
let store = Arc::clone(&store);
|
||||
tokio::spawn(async move { store.settled().await })
|
||||
};
|
||||
|
||||
// The waiter cannot have finished while the operation is in flight.
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!waiter.is_finished());
|
||||
|
||||
store.observe(ServiceHealth::Ready);
|
||||
drop(guard);
|
||||
|
||||
let settled = waiter.await.expect("waiter should not panic");
|
||||
assert!(settled.service_usable());
|
||||
assert!(!settled.op_in_flight);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probing_a_ready_service_records_it() {
|
||||
let store = with_env(FakeEnv::new().service_ready());
|
||||
|
||||
let state = store.probe().await.expect("a ready service should probe cleanly");
|
||||
|
||||
assert!(state.service_usable());
|
||||
assert_eq!(store.state().health, ServiceHealth::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probing_an_incompatible_service_records_a_version_mismatch() {
|
||||
let store = with_env(FakeEnv::new().service_version_mismatch());
|
||||
|
||||
let error = store.probe().await.expect_err("an incompatible service should fail");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("protocol mismatch"),
|
||||
"error should explain the mismatch: {error}"
|
||||
);
|
||||
assert_eq!(store.state().health, ServiceHealth::VersionMismatch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn awaiting_readiness_retries_a_silent_service_then_accepts_its_reply() {
|
||||
let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".to_owned()), Ok(ready_reply())]));
|
||||
|
||||
let state = store
|
||||
.await_ready(2, Duration::ZERO)
|
||||
.await
|
||||
.expect("the second attempt should succeed");
|
||||
|
||||
assert!(state.service_usable());
|
||||
assert_eq!(store.env.probe_count(), 2, "the first attempt should have been retried");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn awaiting_readiness_gives_up_after_the_last_attempt() {
|
||||
let store = with_env(FakeEnv::new().service_unreachable());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
let error = store
|
||||
.await_ready(3, Duration::ZERO)
|
||||
.await
|
||||
.expect_err("a silent service should never become ready");
|
||||
|
||||
assert!(matches!(error, ReadyWaitError::Unreachable(_)));
|
||||
assert_eq!(store.env.probe_count(), 3, "every attempt should have been used");
|
||||
assert_eq!(
|
||||
store.state().health,
|
||||
ServiceHealth::Ready,
|
||||
"silence is the caller's to interpret, not an observation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn awaiting_readiness_stops_at_the_first_readable_reply() {
|
||||
// Retrying an incompatible version would only delay telling the user to reinstall.
|
||||
let store = with_env(FakeEnv::new().service_version_mismatch());
|
||||
|
||||
let error = store
|
||||
.await_ready(20, Duration::ZERO)
|
||||
.await
|
||||
.expect_err("an incompatible service is not ready");
|
||||
|
||||
assert!(matches!(error, ReadyWaitError::Rejected(_)));
|
||||
assert_eq!(store.env.probe_count(), 1, "a readable reply ends the wait");
|
||||
assert_eq!(store.state().health, ServiceHealth::VersionMismatch);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn awaiting_readiness_with_no_attempts_is_unreachable_not_ready() {
|
||||
let store = with_env(FakeEnv::new().service_ready());
|
||||
|
||||
let error = store
|
||||
.await_ready(0, Duration::ZERO)
|
||||
.await
|
||||
.expect_err("no attempts cannot confirm anything");
|
||||
|
||||
assert!(matches!(error, ReadyWaitError::Unreachable(_)));
|
||||
assert_eq!(store.env.probe_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transport_failure_does_not_overwrite_confirmed_health() {
|
||||
// A Service that is restarting must not be downgraded to "unavailable" on one
|
||||
// missed probe — the last confirmed observation stands until we hear otherwise.
|
||||
let store = with_env(FakeEnv::new().service_unreachable());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
let error = store.probe().await.expect_err("an unreachable service should fail");
|
||||
|
||||
assert!(
|
||||
error.to_string().contains("service readiness probe failed"),
|
||||
"error should name the failed probe: {error}"
|
||||
);
|
||||
assert_eq!(store.state().health, ServiceHealth::Ready);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detection_without_platform_evidence_is_not_installed() {
|
||||
let store = with_env(FakeEnv::new().with_evidence(false));
|
||||
|
||||
assert_eq!(store.detect_service_health().await, ServiceHealth::NotInstalled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detection_skips_the_probe_when_there_is_no_evidence() {
|
||||
let env = FakeEnv::new().with_evidence(false);
|
||||
let store = with_env(env);
|
||||
|
||||
store.detect_service_health().await;
|
||||
|
||||
assert_eq!(store.env.probe_count(), 0, "no evidence means nothing to probe");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detection_reports_a_registered_but_unreachable_service_as_unavailable() {
|
||||
let store = with_env(FakeEnv::new().service_unreachable());
|
||||
|
||||
let health = store.detect_service_health().await;
|
||||
|
||||
assert!(
|
||||
matches!(health, ServiceHealth::Unavailable(_)),
|
||||
"registered but silent is unavailable, not absent: {health:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detection_treats_an_uninspectable_registry_as_unavailable() {
|
||||
let store = with_env(FakeEnv::new().evidence_unavailable());
|
||||
|
||||
let health = store.detect_service_health().await;
|
||||
|
||||
assert!(
|
||||
matches!(health, ServiceHealth::Unavailable(_)),
|
||||
"an uninspectable registry must not be reported as absent: {health:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detection_reports_a_healthy_service_as_ready() {
|
||||
let store = with_env(FakeEnv::new().service_ready());
|
||||
|
||||
assert_eq!(store.detect_service_health().await, ServiceHealth::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_mode_round_trips() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
assert_eq!(*store.mode_arc(), RunningMode::NotRunning);
|
||||
|
||||
store.core_started(RunningMode::Service);
|
||||
|
||||
assert_eq!(*store.mode_arc(), RunningMode::Service);
|
||||
assert_eq!(store.state().mode, RunningMode::Service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_the_core_opens_pac_and_mirrors_the_mode() {
|
||||
for mode in [RunningMode::Service, RunningMode::Sidecar] {
|
||||
let store = with_env(FakeEnv::new());
|
||||
|
||||
store.core_started(mode);
|
||||
|
||||
assert_eq!(store.env.pac_available(), Some(true), "{mode} should open PAC");
|
||||
assert_eq!(store.env.published_modes(), vec![mode]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_the_core_closes_pac_and_mirrors_the_mode() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.core_started(RunningMode::Sidecar);
|
||||
|
||||
store.core_stopped();
|
||||
|
||||
assert_eq!(store.state().mode, RunningMode::NotRunning);
|
||||
assert_eq!(store.env.pac_available(), Some(false));
|
||||
assert_eq!(
|
||||
store.env.published_modes(),
|
||||
vec![RunningMode::Sidecar, RunningMode::NotRunning]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_attempt_closes_pac_without_disturbing_the_mode() {
|
||||
// A handover must not hand out a PAC script for a proxy port between owners, but the
|
||||
// Core we are replacing is still the one that is running.
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.core_started(RunningMode::Sidecar);
|
||||
|
||||
store.core_starting();
|
||||
|
||||
assert_eq!(store.env.pac_available(), Some(false));
|
||||
assert_eq!(store.state().mode, RunningMode::Sidecar);
|
||||
assert_eq!(
|
||||
store.env.published_modes(),
|
||||
vec![RunningMode::Sidecar],
|
||||
"a start attempt is not a mode change"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pac_availability_can_never_disagree_with_the_running_mode() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
|
||||
for mode in [
|
||||
RunningMode::Sidecar,
|
||||
RunningMode::NotRunning,
|
||||
RunningMode::Service,
|
||||
RunningMode::NotRunning,
|
||||
] {
|
||||
store.core_started(mode);
|
||||
|
||||
let running = !matches!(mode, RunningMode::NotRunning);
|
||||
assert_eq!(store.env.pac_available(), Some(running), "mode {mode}");
|
||||
assert_eq!(store.state().mode, mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_change_is_published_not_just_mode_changes() {
|
||||
// The frontend is pushed to rather than polling, so a health change it would otherwise
|
||||
// wait up to a poll interval to notice has to be announced too.
|
||||
let store = with_env(FakeEnv::new());
|
||||
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
store.require_install_for_session().expect("absent service");
|
||||
store.core_started(RunningMode::Sidecar);
|
||||
|
||||
let published = store.env.published();
|
||||
assert_eq!(published.len(), 3, "each change announces exactly once");
|
||||
assert_eq!(published[0].health, ServiceHealth::NotInstalled);
|
||||
assert_eq!(published[1].pending, Some(PendingAction::Install));
|
||||
assert_eq!(published[2].mode, RunningMode::Sidecar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_op_change_announces_nothing() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let published = store.env.published().len();
|
||||
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
assert_eq!(store.env.published().len(), published);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_and_finishing_an_operation_are_both_published() {
|
||||
// op_in_flight is part of the snapshot, so the frontend must learn when it clears.
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let baseline = store.env.published().len();
|
||||
|
||||
let guard = store.begin_operation().expect("slot should be free");
|
||||
drop(guard);
|
||||
|
||||
let published = store.env.published();
|
||||
assert_eq!(published.len(), baseline + 2);
|
||||
assert!(published[baseline].op_in_flight, "the claim is announced");
|
||||
assert!(!published[baseline + 1].op_in_flight, "so is the release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_health_survives_the_core_stopping() {
|
||||
// Stopping the Core says nothing about whether the Service is installed.
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
store.core_started(RunningMode::Service);
|
||||
|
||||
store.core_stopped();
|
||||
|
||||
assert_eq!(store.state().health, ServiceHealth::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_capable_when_elevated_even_with_no_service() {
|
||||
let store = with_env(FakeEnv::new().elevated());
|
||||
store.observe(ServiceHealth::NotInstalled);
|
||||
|
||||
assert!(store.state().tun_capable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_capable_via_a_ready_service_without_elevation() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
|
||||
assert!(store.state().tun_capable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_is_not_capable_mid_operation_without_elevation() {
|
||||
let store = with_env(FakeEnv::new());
|
||||
store.observe(ServiceHealth::Ready);
|
||||
let _guard = store.begin_operation().expect("slot should be free");
|
||||
|
||||
assert!(!store.state().tun_capable());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use clash_verge_service_ipc::{RuntimeAsset, RuntimeBundle};
|
||||
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};
|
||||
@@ -24,6 +25,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path)
|
||||
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,
|
||||
@@ -31,6 +33,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path)
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
&mut remote_providers,
|
||||
)?;
|
||||
collect_provider_assets(
|
||||
&mut config,
|
||||
@@ -38,6 +41,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path)
|
||||
&config_root,
|
||||
&mut destinations,
|
||||
&mut assets,
|
||||
&mut remote_providers,
|
||||
)?;
|
||||
for filename in GEO_ASSETS {
|
||||
let source = config_root.join(filename);
|
||||
@@ -52,16 +56,25 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path)
|
||||
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()
|
||||
@@ -71,23 +84,81 @@ fn collect_provider_assets(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for provider in providers.values_mut().filter_map(Value::as_mapping_mut) {
|
||||
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;
|
||||
};
|
||||
let is_remote = provider.get("url").and_then(Value::as_str).is_some();
|
||||
let destination = if is_remote {
|
||||
provider_destination(config_root, raw_path)?
|
||||
} else {
|
||||
let source = local_provider_source(config_root, raw_path)?;
|
||||
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(),
|
||||
});
|
||||
// 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
|
||||
}
|
||||
destination
|
||||
};
|
||||
provider.insert(Value::String("path".to_owned()), Value::String(destination));
|
||||
}
|
||||
@@ -210,6 +281,18 @@ mod tests {
|
||||
);
|
||||
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
|
||||
@@ -220,6 +303,85 @@ mod tests {
|
||||
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()));
|
||||
|
||||
+537
-865
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,8 @@
|
||||
use crate::{config::Config, singleton, utils::server};
|
||||
use crate::{
|
||||
config::{Config, MixedPort},
|
||||
singleton,
|
||||
utils::server,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::RwLock;
|
||||
@@ -148,10 +152,9 @@ impl Sysopt {
|
||||
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(),
|
||||
};
|
||||
// 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;
|
||||
|
||||
@@ -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,7 +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::SERVICE_MANAGER.is_ready_cached();
|
||||
let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable();
|
||||
let mode = {
|
||||
Config::clash()
|
||||
.await
|
||||
|
||||
+502
-19
@@ -36,6 +36,7 @@ struct ConfigValues {
|
||||
socks_enabled: bool,
|
||||
http_enabled: bool,
|
||||
enable_dns_settings: bool,
|
||||
enable_external_controller: bool,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
redir_enabled: bool,
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -116,8 +117,10 @@ async fn get_config_values() -> ConfigValues {
|
||||
ref verge_socks_enabled,
|
||||
ref verge_http_enabled,
|
||||
ref enable_dns_settings,
|
||||
ref enable_external_controller,
|
||||
..
|
||||
} = **verge_arc;
|
||||
let enable_external_controller = enable_external_controller.unwrap_or(false);
|
||||
|
||||
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
|
||||
Some(verge_arc.get_valid_clash_core()),
|
||||
@@ -145,6 +148,7 @@ async fn get_config_values() -> ConfigValues {
|
||||
socks_enabled,
|
||||
http_enabled,
|
||||
enable_dns_settings,
|
||||
enable_external_controller,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
redir_enabled,
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -330,6 +334,32 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"unified-delay",
|
||||
];
|
||||
|
||||
/// The fields the app owns, held across the stages that apply the user's manual overrides.
|
||||
///
|
||||
/// Capturing and restoring are one value rather than two calls because the order is a
|
||||
/// correctness requirement, not a style: capture must happen after the app has finished
|
||||
/// deriving these fields and before any override runs, and restore must happen after every
|
||||
/// override. As four separate calls that contract lived only in comments.
|
||||
struct AuthoritativeFields {
|
||||
control_plane: Mapping,
|
||||
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
|
||||
dns_ipv6: Option<Value>,
|
||||
}
|
||||
|
||||
impl AuthoritativeFields {
|
||||
fn capture(config: &Mapping, enable_dns_settings: bool) -> Self {
|
||||
Self {
|
||||
control_plane: snapshot_control_plane(config),
|
||||
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce(self, config: Mapping) -> Mapping {
|
||||
let config = enforce_control_plane(config, self.control_plane);
|
||||
enforce_dns_ipv6(config, self.dns_ipv6)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。
|
||||
fn snapshot_control_plane(config: &Mapping) -> Mapping {
|
||||
let mut snapshot = Mapping::new();
|
||||
@@ -440,11 +470,17 @@ async fn process_profile_items(
|
||||
(config, exists_keys, result_map)
|
||||
}
|
||||
|
||||
async fn merge_default_config(
|
||||
/// Merge the Application Merge Config over the profile's own configuration.
|
||||
///
|
||||
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
|
||||
/// out of the global config from inside its loop, which made a stage that looks pure depend
|
||||
/// on process state and left it untestable.
|
||||
fn merge_default_config(
|
||||
mut config: Mapping,
|
||||
clash_config: Mapping,
|
||||
socks_enabled: bool,
|
||||
http_enabled: bool,
|
||||
enable_external_controller: bool,
|
||||
#[cfg(not(target_os = "windows"))] redir_enabled: bool,
|
||||
#[cfg(target_os = "linux")] tproxy_enabled: bool,
|
||||
) -> Mapping {
|
||||
@@ -496,12 +532,6 @@ async fn merge_default_config(
|
||||
}
|
||||
// 处理 external-controller 键的开关逻辑
|
||||
if key.as_str() == Some("external-controller") {
|
||||
let enable_external_controller = Config::verge()
|
||||
.await
|
||||
.latest_arc()
|
||||
.enable_external_controller
|
||||
.unwrap_or(false);
|
||||
|
||||
if enable_external_controller {
|
||||
config.insert(key, value);
|
||||
} else {
|
||||
@@ -694,6 +724,7 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
socks_enabled,
|
||||
http_enabled,
|
||||
enable_dns_settings,
|
||||
enable_external_controller,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
redir_enabled,
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -724,26 +755,20 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
clash_config,
|
||||
socks_enabled,
|
||||
http_enabled,
|
||||
enable_external_controller,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
redir_enabled,
|
||||
#[cfg(target_os = "linux")]
|
||||
tproxy_enabled,
|
||||
)
|
||||
.await;
|
||||
);
|
||||
|
||||
// app 生成项先于手动覆盖。
|
||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
|
||||
let config = use_tun(config, enable_tun);
|
||||
let config = apply_dns_settings(config, enable_dns_settings).await;
|
||||
|
||||
// 手动覆盖前锁定 app 权威字段。
|
||||
let control_plane = snapshot_control_plane(&config);
|
||||
// DNS 页开启时,仅 `dns.ipv6` 跟随 UI;其余 DNS 字段仍可覆盖。
|
||||
let dns_ipv6 = if enable_dns_settings {
|
||||
snapshot_dns_ipv6(&config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 手动覆盖前锁定 app 权威字段,覆盖后由同一个值恢复。
|
||||
let authoritative = AuthoritativeFields::capture(&config, enable_dns_settings);
|
||||
|
||||
// 全局手动覆盖。
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
@@ -761,8 +786,7 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
|
||||
|
||||
// 手动覆盖后恢复 app 权威字段。
|
||||
let config = enforce_control_plane(config, control_plane);
|
||||
let config = enforce_dns_ipv6(config, dns_ipv6);
|
||||
let config = authoritative.enforce(config);
|
||||
let config = ensure_lan_bind_address(config);
|
||||
|
||||
let config = cleanup_proxy_groups(config);
|
||||
@@ -774,6 +798,465 @@ pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, Resu
|
||||
Ok((config, exists_keys_set, result_map))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod fake_ip_tests {
|
||||
use super::{Mapping, Value, ensure_fake_ip_range6};
|
||||
|
||||
fn dns(pairs: &[(&str, Value)]) -> Mapping {
|
||||
let mut map = Mapping::new();
|
||||
for (key, value) in pairs {
|
||||
map.insert(Value::from(*key), value.clone());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
const RANGE6: &str = "fdfe:dcba:9876::1/64";
|
||||
|
||||
#[test]
|
||||
fn an_ipv6_fake_ip_setup_missing_its_range_gets_one() {
|
||||
let mut config = dns(&[("ipv6", Value::from(true)), ("enhanced-mode", Value::from("fake-ip"))]);
|
||||
|
||||
ensure_fake_ip_range6(&mut config);
|
||||
|
||||
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fake_ip_is_the_assumed_mode_when_unstated() {
|
||||
let mut config = dns(&[("ipv6", Value::from(true))]);
|
||||
|
||||
ensure_fake_ip_range6(&mut config);
|
||||
|
||||
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_empty_range_counts_as_missing() {
|
||||
// The reason this is not just a `contains_key` check: YAML edited by hand often
|
||||
// leaves the key present with nothing after the colon.
|
||||
for blank in ["", " "] {
|
||||
let mut config = dns(&[
|
||||
("ipv6", Value::from(true)),
|
||||
("enhanced-mode", Value::from("fake-ip")),
|
||||
("fake-ip-range6", Value::from(blank)),
|
||||
]);
|
||||
|
||||
ensure_fake_ip_range6(&mut config);
|
||||
|
||||
assert_eq!(
|
||||
config.get(Value::from("fake-ip-range6")),
|
||||
Some(&Value::from(RANGE6)),
|
||||
"blank range {blank:?} should be filled in"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_range_is_never_overwritten() {
|
||||
let mut config = dns(&[
|
||||
("ipv6", Value::from(true)),
|
||||
("enhanced-mode", Value::from("fake-ip")),
|
||||
("fake-ip-range6", Value::from("fc00::1/64")),
|
||||
]);
|
||||
|
||||
ensure_fake_ip_range6(&mut config);
|
||||
|
||||
assert_eq!(
|
||||
config.get(Value::from("fake-ip-range6")),
|
||||
Some(&Value::from("fc00::1/64"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_added_without_ipv6_or_outside_fake_ip() {
|
||||
let mut without_ipv6 = dns(&[("enhanced-mode", Value::from("fake-ip"))]);
|
||||
ensure_fake_ip_range6(&mut without_ipv6);
|
||||
assert!(!without_ipv6.contains_key(Value::from("fake-ip-range6")));
|
||||
|
||||
let mut redir_host = dns(&[
|
||||
("ipv6", Value::from(true)),
|
||||
("enhanced-mode", Value::from("redir-host")),
|
||||
]);
|
||||
ensure_fake_ip_range6(&mut redir_host);
|
||||
assert!(!redir_host.contains_key(Value::from("fake-ip-range6")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod use_tun_tests {
|
||||
use super::{Mapping, Value, use_tun};
|
||||
|
||||
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
|
||||
let mut map = Mapping::new();
|
||||
for (key, value) in pairs {
|
||||
map.insert(Value::from(*key), value.clone());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn tun_of(config: &Mapping) -> &Mapping {
|
||||
config
|
||||
.get(Value::from("tun"))
|
||||
.and_then(Value::as_mapping)
|
||||
.expect("tun should be a mapping")
|
||||
}
|
||||
|
||||
fn dns_of(config: &Mapping) -> Option<&Mapping> {
|
||||
config.get(Value::from("dns")).and_then(Value::as_mapping)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_switch_is_written_either_way() {
|
||||
assert_eq!(
|
||||
tun_of(&use_tun(Mapping::new(), true)).get(Value::from("enable")),
|
||||
Some(&Value::from(true))
|
||||
);
|
||||
assert_eq!(
|
||||
tun_of(&use_tun(Mapping::new(), false)).get(Value::from("enable")),
|
||||
Some(&Value::from(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_tun_keys_survive_being_switched_on() {
|
||||
let config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
|
||||
|
||||
let result = use_tun(config, true);
|
||||
|
||||
assert_eq!(tun_of(&result).get(Value::from("stack")), Some(&Value::from("gvisor")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabling_tun_sets_up_fake_ip_dns() {
|
||||
let result = use_tun(Mapping::new(), true);
|
||||
|
||||
let dns = dns_of(&result).expect("enabling tun should write dns settings");
|
||||
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(true)));
|
||||
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("fake-ip")));
|
||||
assert_eq!(
|
||||
dns.get(Value::from("fake-ip-range")),
|
||||
Some(&Value::from("198.18.0.1/16"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deliberate_redir_host_setup_is_left_alone() {
|
||||
// Only fake-ip DNS is ours to configure; someone who chose redir-host meant it.
|
||||
let config = mapping(&[(
|
||||
"dns",
|
||||
Value::Mapping(mapping(&[("enhanced-mode", Value::from("redir-host"))])),
|
||||
)]);
|
||||
|
||||
let result = use_tun(config, true);
|
||||
|
||||
let dns = dns_of(&result).expect("dns should still be present");
|
||||
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("redir-host")));
|
||||
assert!(!dns.contains_key(Value::from("fake-ip-range")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_ipv6_fake_ip_range_follows_the_top_level_switch() {
|
||||
let with_ipv6 = use_tun(mapping(&[("ipv6", Value::from(true))]), true);
|
||||
let dns = dns_of(&with_ipv6).expect("dns should be written");
|
||||
assert_eq!(dns.get(Value::from("ipv6")), Some(&Value::from(true)));
|
||||
assert_eq!(
|
||||
dns.get(Value::from("fake-ip-range6")),
|
||||
Some(&Value::from("fdfe:dcba:9876::1/64"))
|
||||
);
|
||||
|
||||
let without_ipv6 = use_tun(Mapping::new(), true);
|
||||
let dns = dns_of(&without_ipv6).expect("dns should be written");
|
||||
assert!(!dns.contains_key(Value::from("fake-ip-range6")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_tun_off_leaves_dns_untouched() {
|
||||
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
|
||||
// resolver is restored, which is not part of the configuration.
|
||||
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
|
||||
|
||||
let result = use_tun(config, false);
|
||||
|
||||
let dns = dns_of(&result).expect("dns should be untouched");
|
||||
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(false)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod use_sort_tests {
|
||||
use super::{Mapping, Value, use_sort};
|
||||
|
||||
#[test]
|
||||
fn every_key_survives_sorting() {
|
||||
let mut config = Mapping::new();
|
||||
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
|
||||
config.insert(Value::from(key), Value::from(key));
|
||||
}
|
||||
let expected = config.len();
|
||||
|
||||
let sorted = use_sort(config);
|
||||
|
||||
assert_eq!(sorted.len(), expected, "sorting must not drop or invent keys");
|
||||
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
|
||||
assert_eq!(sorted.get(Value::from(key)), Some(&Value::from(key)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bulky_list_fields_are_written_last() {
|
||||
// These are the fields that make a config file unreadable from the top, so they are
|
||||
// pushed below the settings a human actually scans for.
|
||||
let mut config = Mapping::new();
|
||||
config.insert(Value::from("rules"), Value::from("rules"));
|
||||
config.insert(Value::from("mode"), Value::from("rule"));
|
||||
|
||||
let sorted = use_sort(config);
|
||||
let order: Vec<_> = sorted.keys().filter_map(Value::as_str).collect();
|
||||
|
||||
let mode = order.iter().position(|key| *key == "mode");
|
||||
let rules = order.iter().position(|key| *key == "rules");
|
||||
assert!(mode < rules, "expected mode before rules, got {order:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_is_stable_when_applied_twice() {
|
||||
let mut config = Mapping::new();
|
||||
for key in ["proxies", "mode", "unknown-key", "rules"] {
|
||||
config.insert(Value::from(key), Value::from(1));
|
||||
}
|
||||
|
||||
let once = use_sort(config);
|
||||
let twice = use_sort(once.clone());
|
||||
|
||||
assert_eq!(once.keys().collect::<Vec<_>>(), twice.keys().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod merge_default_config_tests {
|
||||
use super::{Mapping, Value, merge_default_config};
|
||||
|
||||
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
|
||||
let mut map = Mapping::new();
|
||||
for (key, value) in pairs {
|
||||
map.insert(Value::from(*key), value.clone());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Call with every listener enabled, so a test only says what it is turning off.
|
||||
fn merge(config: Mapping, clash_config: Mapping, external_controller: bool) -> Mapping {
|
||||
merge_default_config(
|
||||
config,
|
||||
clash_config,
|
||||
true,
|
||||
true,
|
||||
external_controller,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
true,
|
||||
#[cfg(target_os = "linux")]
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_without_optional_listeners(config: Mapping, clash_config: Mapping) -> Mapping {
|
||||
merge_default_config(
|
||||
config,
|
||||
clash_config,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
false,
|
||||
#[cfg(target_os = "linux")]
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_config_overwrites_the_profile() {
|
||||
let config = mapping(&[("mode", Value::from("rule"))]);
|
||||
let clash_config = mapping(&[("mode", Value::from("global"))]);
|
||||
|
||||
let merged = merge(config, clash_config, true);
|
||||
|
||||
assert_eq!(merged.get(Value::from("mode")), Some(&Value::from("global")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_the_merge_config_says_nothing_about_survive() {
|
||||
let config = mapping(&[("profile-only", Value::from(1))]);
|
||||
|
||||
let merged = merge(config, Mapping::new(), true);
|
||||
|
||||
assert_eq!(merged.get(Value::from("profile-only")), Some(&Value::from(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_listener_is_removed_rather_than_merged() {
|
||||
// Removed, not just skipped: the profile may have asked for the port itself, and a
|
||||
// listener the user switched off must not be opened by the profile's own value.
|
||||
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
|
||||
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
|
||||
|
||||
let merged = merge_without_optional_listeners(config, clash_config);
|
||||
|
||||
assert!(!merged.contains_key(Value::from("socks-port")));
|
||||
assert!(!merged.contains_key(Value::from("port")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enabled_listener_takes_the_merge_config_value() {
|
||||
let config = mapping(&[("socks-port", Value::from(7891))]);
|
||||
let clash_config = mapping(&[("socks-port", Value::from(1080))]);
|
||||
|
||||
let merged = merge(config, clash_config, true);
|
||||
|
||||
assert_eq!(merged.get(Value::from("socks-port")), Some(&Value::from(1080)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
|
||||
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
|
||||
// listen anyway, which is the opposite of what switching it off means.
|
||||
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
||||
|
||||
let merged = merge(Mapping::new(), clash_config, false);
|
||||
|
||||
assert_eq!(
|
||||
merged.get(Value::from("external-controller")),
|
||||
Some(&Value::from("")),
|
||||
"the key must remain, holding an empty value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enabled_external_controller_keeps_its_address() {
|
||||
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
||||
|
||||
let merged = merge(Mapping::new(), clash_config, true);
|
||||
|
||||
assert_eq!(
|
||||
merged.get(Value::from("external-controller")),
|
||||
Some(&Value::from("127.0.0.1:9090"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tun_settings_are_merged_key_by_key_rather_than_replaced() {
|
||||
// The profile's other tun keys have to survive a merge config that sets only one.
|
||||
let config = mapping(&[(
|
||||
"tun",
|
||||
Value::Mapping(mapping(&[
|
||||
("device", Value::from("utun9")),
|
||||
("stack", Value::from("system")),
|
||||
])),
|
||||
)]);
|
||||
let clash_config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
|
||||
|
||||
let merged = merge(config, clash_config, true);
|
||||
|
||||
let tun = merged
|
||||
.get(Value::from("tun"))
|
||||
.and_then(Value::as_mapping)
|
||||
.expect("tun should still be a mapping");
|
||||
assert_eq!(tun.get(Value::from("device")), Some(&Value::from("utun9")));
|
||||
assert_eq!(tun.get(Value::from("stack")), Some(&Value::from("gvisor")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
|
||||
mod authoritative_field_tests {
|
||||
use super::{AuthoritativeFields, Mapping, Value};
|
||||
|
||||
fn config_with(pairs: &[(&str, Value)]) -> Mapping {
|
||||
let mut config = Mapping::new();
|
||||
for (key, value) in pairs {
|
||||
config.insert(Value::from(*key), value.clone());
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
fn dns_with_ipv6(enabled: bool) -> Value {
|
||||
let mut dns = Mapping::new();
|
||||
dns.insert(Value::from("ipv6"), Value::from(enabled));
|
||||
dns.insert(Value::from("enable"), Value::from(true));
|
||||
Value::Mapping(dns)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_cannot_change_a_field_the_app_owns() {
|
||||
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, false);
|
||||
|
||||
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
|
||||
assert_eq!(result.get(Value::from("mode")), Some(&Value::from("rule")));
|
||||
assert_eq!(result.get(Value::from("secret")), Some(&Value::from("ours")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_cannot_introduce_a_field_the_app_left_out() {
|
||||
// The app decided not to expose the external controller; a profile must not re-add it.
|
||||
let derived = Mapping::new();
|
||||
let authoritative = AuthoritativeFields::capture(&derived, false);
|
||||
|
||||
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
|
||||
assert!(!result.contains_key(Value::from("external-controller")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_the_app_does_not_own_survive_an_override() {
|
||||
let derived = config_with(&[("mode", Value::from("rule"))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, false);
|
||||
|
||||
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
|
||||
let result = authoritative.enforce(overridden);
|
||||
|
||||
assert_eq!(result.get(Value::from("profile-key")), Some(&Value::from(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
|
||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||
|
||||
let owned = AuthoritativeFields::capture(&derived, true);
|
||||
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||
assert_eq!(
|
||||
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||
Some(&Value::from(true)),
|
||||
"with the DNS page on, the app's value wins"
|
||||
);
|
||||
|
||||
let unowned = AuthoritativeFields::capture(&derived, false);
|
||||
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
|
||||
assert_eq!(
|
||||
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
|
||||
Some(&Value::from(false)),
|
||||
"with the DNS page off, an override may set it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_dns_ipv6_never_invents_a_dns_block() {
|
||||
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
|
||||
let authoritative = AuthoritativeFields::capture(&derived, true);
|
||||
|
||||
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
|
||||
let result = authoritative.enforce(Mapping::new());
|
||||
|
||||
assert!(!result.contains_key(Value::from("dns")));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
config::{Config, MixedPort},
|
||||
core::{CoreManager, handle, tray},
|
||||
feat::clean_async,
|
||||
process::AsyncHandler,
|
||||
@@ -142,10 +142,7 @@ pub async fn test_delay(url: String) -> anyhow::Result<u32> {
|
||||
let verge = Config::verge().await.latest_arc();
|
||||
let proxy_enabled = verge.enable_system_proxy.unwrap_or(false) || verge.enable_tun_mode.unwrap_or(false);
|
||||
let proxy_port = if proxy_enabled {
|
||||
Some(match verge.verge_mixed_port {
|
||||
Some(p) => p,
|
||||
None => Config::clash().await.data_arc().get_mixed_port(),
|
||||
})
|
||||
Some(MixedPort::desired().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
};
|
||||
use anyhow::Result;
|
||||
use bitflags::bitflags;
|
||||
use clash_verge_draft::SharedDraft;
|
||||
use clash_verge_draft::{DraftTransaction, SharedDraft};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use serde_yaml_ng::Mapping;
|
||||
|
||||
@@ -269,24 +269,23 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) ->
|
||||
}
|
||||
|
||||
pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
|
||||
Config::verge().await.edit_draft(|d| d.patch_config(patch));
|
||||
let verge = Config::verge().await;
|
||||
// Applying the flags can fail, and until now that `?` returned straight out of here past
|
||||
// an `if let Err(..) { discard() }` the compiler could never reach — leaving the failed
|
||||
// edit sitting in the draft, where every later reader saw a value that was never applied
|
||||
// and never written to disk.
|
||||
let transaction = DraftTransaction::new(vec![&verge]);
|
||||
verge.edit_draft(|d| d.patch_config(patch));
|
||||
|
||||
let update_flags = determine_update_flags(patch);
|
||||
logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags);
|
||||
let process_flag_result: std::result::Result<(), anyhow::Error> = {
|
||||
process_terminated_flags(update_flags, patch).await?;
|
||||
Ok(())
|
||||
};
|
||||
process_terminated_flags(update_flags, patch).await?;
|
||||
transaction.commit();
|
||||
|
||||
if let Err(err) = process_flag_result {
|
||||
Config::verge().await.discard();
|
||||
return Err(err);
|
||||
}
|
||||
Config::verge().await.apply();
|
||||
logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await);
|
||||
if !not_save_file {
|
||||
// 分离数据获取和异步调用
|
||||
let verge_data = Config::verge().await.data_arc();
|
||||
let verge_data = verge.data_arc();
|
||||
logging!(debug, Type::Setup, "Saving Verge configuration to file...");
|
||||
verge_data.save_file().await?;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
process::AsyncHandler,
|
||||
};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
|
||||
@@ -36,60 +37,45 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
manager.finish_config_update();
|
||||
}
|
||||
|
||||
// Read before opening the transaction: rolling back discards whatever draft each layer
|
||||
// holds, so a transaction opened before this `?` would throw away drafts staged by
|
||||
// someone else that this function never touched.
|
||||
let current = current_runtime_mapping().await?;
|
||||
|
||||
let clash = Config::clash().await;
|
||||
let verge = Config::verge().await;
|
||||
let runtime = Config::runtime().await;
|
||||
// Every rejection and every failure below leaves the three layers as they were.
|
||||
let transaction = DraftTransaction::new(vec![&clash, &verge, &runtime]);
|
||||
|
||||
stage_proxy_ports(&settings).await;
|
||||
if let Err(error) = Config::generate().await {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error).context("failed to generate candidate proxy port configuration");
|
||||
}
|
||||
let candidate = match latest_runtime_mapping().await {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// The candidate ports are staged but nothing is serving them yet, so close PAC rather
|
||||
// than hand out a script for a port that is between owners.
|
||||
manager.core_starting();
|
||||
Config::generate()
|
||||
.await
|
||||
.context("failed to generate candidate proxy port configuration")?;
|
||||
let candidate = latest_runtime_mapping().await?;
|
||||
|
||||
let was_running = !matches!(*manager.get_running_mode(), RunningMode::NotRunning);
|
||||
let assessment = match probe_proxy_ports(current.clone(), candidate.clone(), was_running).await {
|
||||
Ok(assessment) => assessment,
|
||||
Err(error) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
match rejected_save_outcome(assessment) {
|
||||
Ok(Some(outcome)) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Ok(outcome);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error);
|
||||
}
|
||||
let assessment = probe_proxy_ports(current.clone(), candidate.clone(), was_running).await?;
|
||||
if let Some(outcome) = rejected_save_outcome(assessment)? {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let validation = match CoreConfigValidator::global().validate_config_outcome().await {
|
||||
Ok(validation) => validation,
|
||||
Err(error) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error).context("failed to validate candidate proxy port configuration");
|
||||
}
|
||||
};
|
||||
let validation = CoreConfigValidator::global()
|
||||
.validate_config_outcome()
|
||||
.await
|
||||
.context("failed to validate candidate proxy port configuration")?;
|
||||
if !validation.is_valid() {
|
||||
discard_proxy_port_drafts().await;
|
||||
bail!("candidate proxy port configuration is invalid: {validation}");
|
||||
}
|
||||
|
||||
let snapshots = match capture_config_files().await {
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(error) => {
|
||||
discard_proxy_port_drafts().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let snapshots = capture_config_files().await?;
|
||||
// From here the drafts alone are no longer enough to undo things: files are on disk and
|
||||
// the core may be restarted, so failures restore explicitly as well as rolling back.
|
||||
if let Err(error) = Config::generate_file(ConfigType::Run).await {
|
||||
discard_proxy_port_drafts().await;
|
||||
transaction.rollback();
|
||||
return match restore_files(&snapshots).await {
|
||||
Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"),
|
||||
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
|
||||
@@ -97,6 +83,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
}
|
||||
|
||||
if was_running && let Err(activation_error) = manager.restart_core().await {
|
||||
transaction.rollback();
|
||||
if let Err(rollback_error) = rollback_proxy_ports(&snapshots, was_running).await {
|
||||
return Err(anyhow!(
|
||||
"failed to activate proxy port configuration: {activation_error:#}; \
|
||||
@@ -117,6 +104,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
}
|
||||
|
||||
if let Err(persist_error) = persist_proxy_port_sources().await {
|
||||
transaction.rollback();
|
||||
return match rollback_proxy_ports(&snapshots, was_running).await {
|
||||
Ok(()) => Err(persist_error),
|
||||
Err(rollback_error) => Err(anyhow!(
|
||||
@@ -125,9 +113,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
};
|
||||
}
|
||||
|
||||
Config::clash().await.apply();
|
||||
Config::verge().await.apply();
|
||||
Config::runtime().await.apply();
|
||||
transaction.commit();
|
||||
Handle::refresh_clash();
|
||||
Handle::refresh_verge();
|
||||
logging!(info, Type::Config, "Proxy port configuration applied and persisted");
|
||||
@@ -218,6 +204,10 @@ async fn persist_proxy_port_sources() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Roll the drafts back *now*, rather than on the way out.
|
||||
///
|
||||
/// The transaction discards when it drops, but rollback has to regenerate files from the
|
||||
/// committed configuration first, so the drafts must be gone before that happens.
|
||||
async fn discard_proxy_port_drafts() {
|
||||
Config::clash().await.discard();
|
||||
Config::verge().await.discard();
|
||||
|
||||
@@ -5,6 +5,7 @@ mod icon;
|
||||
mod listener;
|
||||
mod profile;
|
||||
mod proxy;
|
||||
mod tun;
|
||||
mod window;
|
||||
|
||||
// Re-export all functions from modules
|
||||
@@ -15,4 +16,5 @@ pub use icon::*;
|
||||
pub use listener::*;
|
||||
pub use profile::*;
|
||||
pub use proxy::*;
|
||||
pub use tun::*;
|
||||
pub use window::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
config::{Config, IVerge, MixedPort},
|
||||
core::handle,
|
||||
};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
@@ -77,7 +77,10 @@ pub async fn copy_clash_env() {
|
||||
.unwrap_or_else(|| verge_cfg.proxy_host.as_deref().unwrap_or("127.0.0.1"));
|
||||
|
||||
let app_handle = handle::Handle::app_handle();
|
||||
let port = verge_cfg.verge_mixed_port.unwrap_or(7897);
|
||||
// The user is about to paste this into a shell, so it has to be the port the Core is
|
||||
// really on — and this path is user-triggered, so a round-trip is affordable. It also
|
||||
// used to fall back to a hardcoded 7897, ignoring the Merge Config entirely.
|
||||
let port = MixedPort::effective().await;
|
||||
let http_proxy = format!("http://{ip}:{port}");
|
||||
let socks5_proxy = format!("socks5://{ip}:{port}");
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Turning TUN off when it stops being possible.
|
||||
//!
|
||||
//! The decision is [`RunState::tun_should_be_disabled`]; this is the action it triggers. They
|
||||
//! are separate because the decision is a question about Service capability, which Run State
|
||||
//! owns, while writing user configuration is not something Run State should do.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
core::{handle::Handle, manager::RunningMode, runstate::RUN_STATE},
|
||||
};
|
||||
|
||||
/// Set while a disable is being written.
|
||||
///
|
||||
/// Patching the config produces further Run State transitions, each of which reconciles again;
|
||||
/// without this the first loss of capability would start an unbounded cascade. Cleared on the
|
||||
/// way out however that happens — an early return, an error, or a panic — because a flag stuck
|
||||
/// on would silence every later reconciliation for the life of the process.
|
||||
static DISABLING_TUN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turn TUN off if the Run State says it cannot work.
|
||||
///
|
||||
/// Reads the current Run State rather than taking a snapshot: this is invoked from a spawned
|
||||
/// task, so whatever transition triggered it may be several states out of date by the time it
|
||||
/// runs, and acting on a stale "cannot work" is how a transient startup state would silently
|
||||
/// rewrite the user's configuration.
|
||||
///
|
||||
/// A no-op in every case except a settled one, so it is safe to call on every transition.
|
||||
pub async fn reconcile_tun_availability() {
|
||||
let state = RUN_STATE.state();
|
||||
|
||||
// Before the Core is running the startup decision has not been taken yet: the Service may
|
||||
// be about to be installed, and `prepare_startup` reads `enable_tun_mode` to decide
|
||||
// whether to ask. Turning TUN off here would both undo the user's setting and remove the
|
||||
// very prompt that would have made it work.
|
||||
if matches!(state.mode, RunningMode::NotRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Falling back to Sidecar already suppresses TUN for this session. That is deliberately
|
||||
// not a persistent change — `Config::disable_tun_and_persist` is the separate, explicit
|
||||
// act — so honour the decision that was already taken rather than escalating it.
|
||||
if Config::tun_suppressed_for_session() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if !state.tun_should_be_disabled(tun_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if DISABLING_TUN.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
defer! {
|
||||
DISABLING_TUN.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
Type::Core,
|
||||
"TUN mode cannot work in the current run state; turning it off"
|
||||
);
|
||||
let patch = IVerge {
|
||||
enable_tun_mode: Some(false),
|
||||
..IVerge::default()
|
||||
};
|
||||
|
||||
match super::patch_verge(&patch, false).await {
|
||||
Ok(()) => Handle::notice_message("tun_mode::auto_disabled", ""),
|
||||
Err(error) => {
|
||||
logging!(error, Type::Core, "failed to turn TUN mode off: {error:#}");
|
||||
Handle::notice_message("tun_mode::auto_disable_failed", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,7 @@ mod app_init {
|
||||
cmd::start_core,
|
||||
cmd::stop_core,
|
||||
cmd::restart_core,
|
||||
cmd::get_running_mode,
|
||||
cmd::get_runtime_state,
|
||||
cmd::get_auto_launch_status,
|
||||
cmd::entry_lightweight_mode,
|
||||
cmd::exit_lightweight_mode,
|
||||
@@ -158,7 +158,6 @@ mod app_init {
|
||||
cmd::uninstall_service,
|
||||
cmd::reinstall_service,
|
||||
cmd::repair_service,
|
||||
cmd::get_service_install_state,
|
||||
cmd::continue_with_sidecar,
|
||||
cmd::get_clash_info,
|
||||
cmd::patch_clash_config,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::MixedPort;
|
||||
use anyhow::Result;
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use reqwest::{
|
||||
@@ -252,13 +252,7 @@ impl NetworkManager {
|
||||
let proxy_url: Option<std::string::String> = match proxy_type {
|
||||
ProxyType::None => None,
|
||||
ProxyType::Localhost => {
|
||||
let port = {
|
||||
let verge_port = Config::verge().await.data_arc().verge_mixed_port;
|
||||
match verge_port {
|
||||
Some(port) => port,
|
||||
None => Config::clash().await.data_arc().get_mixed_port(),
|
||||
}
|
||||
};
|
||||
let port = MixedPort::desired().await;
|
||||
Some(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
ProxyType::System => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::resolve;
|
||||
use crate::{
|
||||
config::{Config, DEFAULT_PAC},
|
||||
config::{Config, DEFAULT_PAC, MixedPort},
|
||||
module::lightweight,
|
||||
process::AsyncHandler,
|
||||
utils::{dirs, window_manager::WindowManager},
|
||||
@@ -209,13 +209,11 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
);
|
||||
}
|
||||
let verge_config = Config::verge().await;
|
||||
let clash_config = Config::clash().await;
|
||||
let verge_data = verge_config.data_arc();
|
||||
let clash_data = clash_config.data_arc();
|
||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
||||
let pac_port = verge_data
|
||||
.verge_mixed_port
|
||||
.unwrap_or_else(|| clash_data.get_mixed_port());
|
||||
// Served per browser request, so this stays a configuration read rather than a
|
||||
// round-trip to the Core. PAC is closed while the Core is between ports anyway.
|
||||
let pac_port = MixedPort::desired().await;
|
||||
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
||||
Ok::<_, warp::Rejection>(
|
||||
warp::http::Response::builder()
|
||||
@@ -445,21 +443,28 @@ fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InstanceRecord, PAC_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record};
|
||||
use super::{
|
||||
InstanceRecord, PAC_INITIAL_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use super::{open_instance_lock, try_lock_instance};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch};
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::atomic::Ordering;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
};
|
||||
#[test]
|
||||
fn pac_is_fail_closed_before_core_readiness() {
|
||||
assert!(!PAC_AVAILABLE.load(Ordering::Acquire));
|
||||
// Asserts the default rather than the live flag: PAC availability is now derived from
|
||||
// the Running Mode by `core::runstate`, so any test that drives a Core transition
|
||||
// legitimately moves the live flag. That the derivation itself can never disagree with
|
||||
// the Running Mode is covered in `core::runstate` against a fake environment.
|
||||
const { assert!(!PAC_INITIAL_AVAILABLE) }
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
|
||||
@@ -33,6 +33,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { EnhancedCard } from '@/components/home/enhanced-card'
|
||||
import type { ProxySortType } from '@/components/proxy/use-filter-sort'
|
||||
import { useGroupDelays } from '@/hooks/use-group-delays'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useProxySelection } from '@/hooks/use-proxy-selection'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
@@ -53,6 +55,7 @@ import {
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
|
||||
|
||||
// 本地存储的键名
|
||||
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group'
|
||||
@@ -68,7 +71,6 @@ interface ProxyOption {
|
||||
}
|
||||
|
||||
// 排序类型: 默认 | 按延迟 | 按字母
|
||||
type ProxySortType = 0 | 1 | 2
|
||||
|
||||
function convertDelayColor(
|
||||
delayValue: number,
|
||||
@@ -233,9 +235,10 @@ export const CurrentProxyCard = () => {
|
||||
const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE)
|
||||
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
|
||||
})
|
||||
const [delaySortRefresh, setDelaySortRefresh] = useState(0)
|
||||
|
||||
const [selectedGroupName, setSelectedGroupName] = useState('')
|
||||
// Sorting reads delays from a store outside React; this hands them over as a value.
|
||||
const delays = useGroupDelays(selectedGroupName || null)
|
||||
|
||||
const autoCheckInProgressRef = useRef(false)
|
||||
const latestTimeoutRef = useRef<number>(
|
||||
@@ -454,17 +457,8 @@ export const CurrentProxyCard = () => {
|
||||
} finally {
|
||||
autoCheckInProgressRef.current = false
|
||||
refreshProxy()
|
||||
if (sortType === 1) {
|
||||
setDelaySortRefresh((prev) => prev + 1)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isDirectMode,
|
||||
refreshProxy,
|
||||
selectedGroupName,
|
||||
selectedProxyName,
|
||||
sortType,
|
||||
])
|
||||
}, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirectMode) return
|
||||
@@ -559,9 +553,6 @@ export const CurrentProxyCard = () => {
|
||||
}
|
||||
|
||||
refreshProxy()
|
||||
if (sortType === 1) {
|
||||
setDelaySortRefresh((prev) => prev + 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算要显示的代理选项(增加非空校验)
|
||||
@@ -572,35 +563,19 @@ export const CurrentProxyCard = () => {
|
||||
const list = [...proxiesToSort]
|
||||
|
||||
if (sortType === 1) {
|
||||
const refreshTick = delaySortRefresh
|
||||
const effectiveTimeout =
|
||||
typeof defaultLatencyTimeout === 'number' && defaultLatencyTimeout > 0
|
||||
? defaultLatencyTimeout
|
||||
: 10000
|
||||
|
||||
const categorizeDelay = (delay: number): [number, number] => {
|
||||
if (!Number.isFinite(delay)) return [5, Number.MAX_SAFE_INTEGER]
|
||||
if (delay > 1e5) return [4, delay]
|
||||
if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) {
|
||||
return [3, delay || effectiveTimeout]
|
||||
}
|
||||
if (delay < 0) return [5, Number.MAX_SAFE_INTEGER]
|
||||
return [0, delay]
|
||||
}
|
||||
: DEFAULT_DELAY_TIMEOUT
|
||||
|
||||
list.sort((a, b) => {
|
||||
const [ar, av] = categorizeDelay(
|
||||
delayManager.getDelayFix(a.member, selectedGroupName),
|
||||
const byDelay = compareByDelay(
|
||||
delays.of(a.member),
|
||||
delays.of(b.member),
|
||||
effectiveTimeout,
|
||||
)
|
||||
const [br, bv] = categorizeDelay(
|
||||
delayManager.getDelayFix(b.member, selectedGroupName),
|
||||
)
|
||||
|
||||
if (ar !== br) return ar - br
|
||||
if (av !== bv) return av - bv
|
||||
return refreshTick >= 0
|
||||
? a.member.ref.name.localeCompare(b.member.ref.name)
|
||||
: 0
|
||||
if (byDelay !== 0) return byDelay
|
||||
return a.member.ref.name.localeCompare(b.member.ref.name)
|
||||
})
|
||||
} else {
|
||||
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
|
||||
@@ -614,11 +589,10 @@ export const CurrentProxyCard = () => {
|
||||
}
|
||||
return sortWithLatency(unsortedProxyOptions)
|
||||
}, [
|
||||
delays,
|
||||
isDirectMode,
|
||||
unsortedProxyOptions,
|
||||
selectedGroupName,
|
||||
sortType,
|
||||
delaySortRefresh,
|
||||
defaultLatencyTimeout,
|
||||
])
|
||||
|
||||
|
||||
@@ -3,21 +3,19 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import { fetchSystemState } from '@/hooks/use-system-state'
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import { useVisibility } from '@/hooks/use-visibility'
|
||||
import {
|
||||
continueWithSidecar,
|
||||
getRunningMode,
|
||||
getServiceInstallState,
|
||||
getRuntimeState,
|
||||
installService,
|
||||
reinstallService,
|
||||
repairService,
|
||||
restartCore,
|
||||
type ServiceInstallState,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { setCacheDataAsync, useQuery } from '@/services/query-client'
|
||||
const installStateQueryKey = ['getServiceInstallState'] as const
|
||||
|
||||
export const ServiceMigrationDialog = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -25,61 +23,49 @@ export const ServiceMigrationDialog = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
|
||||
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
|
||||
const { data: state } = useQuery({
|
||||
queryKey: installStateQueryKey,
|
||||
queryFn: getServiceInstallState,
|
||||
const { data: runState } = useQuery({
|
||||
queryKey: runStateQueryKey,
|
||||
queryFn: getRuntimeState,
|
||||
enabled: true,
|
||||
retry: 1,
|
||||
refetchInterval: pageVisible ? 2000 : false,
|
||||
refetchInterval: pageVisible ? 30000 : false,
|
||||
})
|
||||
const dialogState = stateRefreshFailed ? 'unavailable' : state
|
||||
const open =
|
||||
loading ||
|
||||
workflowIncomplete ||
|
||||
dialogState === 'installRequired' ||
|
||||
dialogState === 'needsReinstall' ||
|
||||
dialogState === 'unavailable'
|
||||
const showCheckingMessage =
|
||||
loading ||
|
||||
(dialogState !== 'installRequired' &&
|
||||
dialogState !== 'needsReinstall' &&
|
||||
dialogState !== 'unavailable')
|
||||
// Whether the service needs a decision is derived once, in Rust, and travels with the
|
||||
// snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise.
|
||||
const needsDecision =
|
||||
stateRefreshFailed || Boolean(runState?.serviceNeedsAttention)
|
||||
// Which of the three remedies the dialog offers. A refresh we could not complete is
|
||||
// treated as an unreachable service, which is what 'repair' is for.
|
||||
const remedy: 'install' | 'repair' | 'reinstall' =
|
||||
runState?.pendingAction === 'install'
|
||||
? 'install'
|
||||
: stateRefreshFailed || runState?.service === 'unavailable'
|
||||
? 'repair'
|
||||
: 'reinstall'
|
||||
const open = loading || workflowIncomplete || needsDecision
|
||||
const showCheckingMessage = loading || !needsDecision
|
||||
|
||||
const refreshInstallState = async () => {
|
||||
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
|
||||
const refreshRunState = async () => {
|
||||
try {
|
||||
const data = await getServiceInstallState()
|
||||
await setCacheDataAsync<ServiceInstallState>(installStateQueryKey, data)
|
||||
const data = await getRuntimeState()
|
||||
await setCacheDataAsync<RunState>(runStateQueryKey, data)
|
||||
setStateRefreshFailed(false)
|
||||
return data
|
||||
} catch (error) {
|
||||
setStateRefreshFailed(true)
|
||||
await setCacheDataAsync<ServiceInstallState>(
|
||||
installStateQueryKey,
|
||||
'unavailable',
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSystemAndRunning = async () => {
|
||||
const [systemState, runningMode] = await Promise.all([
|
||||
fetchSystemState(),
|
||||
getRunningMode(),
|
||||
])
|
||||
await Promise.all([
|
||||
setCacheDataAsync(['getSystemState'], { ...systemState, runningMode }),
|
||||
setCacheDataAsync(['getRunningMode'], runningMode),
|
||||
])
|
||||
}
|
||||
|
||||
const handleServiceAction = async () => {
|
||||
setLoading(true)
|
||||
setWorkflowIncomplete(true)
|
||||
let actionSucceeded = false
|
||||
try {
|
||||
if (dialogState === 'installRequired') {
|
||||
if (remedy === 'install') {
|
||||
await installService()
|
||||
} else if (dialogState === 'unavailable') {
|
||||
} else if (remedy === 'repair') {
|
||||
await repairService()
|
||||
} else {
|
||||
await reinstallService()
|
||||
@@ -94,7 +80,7 @@ export const ServiceMigrationDialog = () => {
|
||||
|
||||
let initialRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
await refreshRunState()
|
||||
initialRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
@@ -120,7 +106,7 @@ export const ServiceMigrationDialog = () => {
|
||||
|
||||
let finalRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
await refreshRunState()
|
||||
finalRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
@@ -128,17 +114,7 @@ export const ServiceMigrationDialog = () => {
|
||||
error,
|
||||
)
|
||||
}
|
||||
let revalidationSucceeded = false
|
||||
try {
|
||||
await refreshSystemAndRunning()
|
||||
revalidationSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.revalidationFailed',
|
||||
error,
|
||||
)
|
||||
}
|
||||
if (restartSucceeded && finalRefreshSucceeded && revalidationSucceeded) {
|
||||
if (restartSucceeded && finalRefreshSucceeded) {
|
||||
setWorkflowIncomplete(false)
|
||||
showNotice.success('layout.components.serviceMigration.success')
|
||||
}
|
||||
@@ -157,7 +133,7 @@ export const ServiceMigrationDialog = () => {
|
||||
|
||||
let installRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
await refreshRunState()
|
||||
installRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
@@ -165,22 +141,12 @@ export const ServiceMigrationDialog = () => {
|
||||
error,
|
||||
)
|
||||
}
|
||||
let revalidationSucceeded = false
|
||||
try {
|
||||
await refreshSystemAndRunning()
|
||||
revalidationSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.revalidationFailed',
|
||||
error,
|
||||
)
|
||||
}
|
||||
if (startupError) {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.sidecarFailed',
|
||||
startupError,
|
||||
)
|
||||
} else if (installRefreshSucceeded && revalidationSucceeded) {
|
||||
} else if (installRefreshSucceeded) {
|
||||
setWorkflowIncomplete(false)
|
||||
}
|
||||
setLoading(false)
|
||||
@@ -191,9 +157,9 @@ export const ServiceMigrationDialog = () => {
|
||||
open={open}
|
||||
title={t('layout.components.serviceMigration.title')}
|
||||
okBtn={t(
|
||||
dialogState === 'installRequired'
|
||||
remedy === 'install'
|
||||
? 'settings.sections.proxyControl.actions.installService'
|
||||
: dialogState === 'unavailable'
|
||||
: remedy === 'repair'
|
||||
? 'layout.components.serviceMigration.repair'
|
||||
: 'layout.components.serviceMigration.reinstall',
|
||||
)}
|
||||
@@ -208,9 +174,9 @@ export const ServiceMigrationDialog = () => {
|
||||
{t(
|
||||
showCheckingMessage
|
||||
? 'layout.components.serviceMigration.checkingMessage'
|
||||
: dialogState === 'installRequired' || dialogState === 'unavailable'
|
||||
? 'layout.components.serviceMigration.unavailableMessage'
|
||||
: 'layout.components.serviceMigration.message',
|
||||
: remedy === 'reinstall'
|
||||
? 'layout.components.serviceMigration.message'
|
||||
: 'layout.components.serviceMigration.unavailableMessage',
|
||||
)}
|
||||
</Alert>
|
||||
</BaseDialog>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
resolveEmptyListReason,
|
||||
resolveProxyListState,
|
||||
} from './proxy-empty-state-model'
|
||||
|
||||
const settled = {
|
||||
isProfilesPending: false,
|
||||
isProxyViewPending: false,
|
||||
isRunningModePending: false,
|
||||
}
|
||||
|
||||
const subscribed = {
|
||||
current: 'a',
|
||||
items: [{ uid: 'a', type: 'remote' }],
|
||||
}
|
||||
|
||||
describe('resolveProxyListState', () => {
|
||||
test('direct mode is its own outcome, not an empty list', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'direct',
|
||||
profiles: { current: undefined, items: [] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'direct' })
|
||||
})
|
||||
|
||||
test('anything still loading outranks every verdict about emptiness', () => {
|
||||
for (const pending of [
|
||||
{ isProfilesPending: true },
|
||||
{ isProxyViewPending: true },
|
||||
{ isRunningModePending: true },
|
||||
]) {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
...pending,
|
||||
mode: 'rule',
|
||||
profiles: { current: undefined, items: [] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'loading' })
|
||||
}
|
||||
})
|
||||
|
||||
test('no subscription at all is reported before anything about proxies', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: { current: undefined, items: [{ uid: 'a', type: 'merge' }] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'empty', reason: 'no-subscriptions' })
|
||||
})
|
||||
|
||||
test('a subscription that is not the active one is its own explanation', () => {
|
||||
const state = resolveProxyListState({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: { current: 'other', items: [{ uid: 'a', type: 'remote' }] },
|
||||
})
|
||||
|
||||
expect(state).toEqual({ kind: 'empty', reason: 'inactive-subscription' })
|
||||
})
|
||||
|
||||
test('with a usable subscription it defers to the list rather than guessing', () => {
|
||||
// The point of the split: this function no longer has an opinion on whether the
|
||||
// renderer will find anything, because the two opinions used to disagree.
|
||||
for (const mode of ['rule', 'script', 'global']) {
|
||||
expect(
|
||||
resolveProxyListState({ ...settled, mode, profiles: subscribed }),
|
||||
).toEqual({
|
||||
kind: 'render',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('it asks nothing about the proxy view at all', () => {
|
||||
// Guarding the shape, not just the behaviour: reintroducing a proxyView input here is
|
||||
// how the second, divergent derivation came back.
|
||||
const inputs = Object.keys({
|
||||
...settled,
|
||||
mode: 'rule',
|
||||
profiles: subscribed,
|
||||
})
|
||||
|
||||
expect(inputs).not.toContain('proxyView')
|
||||
expect(inputs).not.toContain('isChainMode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEmptyListReason', () => {
|
||||
test('a stopped core explains an empty list better than the list does', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'NotRunning',
|
||||
isProxyViewError: false,
|
||||
}),
|
||||
).toBe('core-unavailable')
|
||||
})
|
||||
|
||||
test('a failed proxy fetch is reported as the core being unavailable', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'Sidecar',
|
||||
isProxyViewError: true,
|
||||
}),
|
||||
).toBe('core-unavailable')
|
||||
})
|
||||
|
||||
test('a running core with a clean fetch means the profile simply has nothing', () => {
|
||||
expect(
|
||||
resolveEmptyListReason({
|
||||
runningMode: 'Sidecar',
|
||||
isProxyViewError: false,
|
||||
}),
|
||||
).toBe('no-proxy-info')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ProxyViewV1 } from '@/types/proxy-view'
|
||||
|
||||
export type ProxyEmptyStateReason =
|
||||
| 'no-subscriptions'
|
||||
| 'inactive-subscription'
|
||||
@@ -14,75 +12,74 @@ type ProfileSummary = {
|
||||
}>
|
||||
}
|
||||
|
||||
interface ProxyEmptyStateInput {
|
||||
interface ProxyListStateInput {
|
||||
mode: string
|
||||
isChainMode: boolean
|
||||
profiles?: ProfileSummary
|
||||
isProfilesPending: boolean
|
||||
proxyView?: ProxyViewV1
|
||||
isProxyViewPending: boolean
|
||||
isProxyViewError: boolean
|
||||
runningMode?: string
|
||||
isRunningModePending: boolean
|
||||
}
|
||||
|
||||
const isSubscriptionProfile = (item: { type?: string }): boolean =>
|
||||
item.type === 'remote' || item.type === 'local'
|
||||
|
||||
const hasRenderableProxyContent = (
|
||||
proxyView: ProxyViewV1 | undefined,
|
||||
mode: string,
|
||||
isChainMode: boolean,
|
||||
) => {
|
||||
if (!proxyView) return false
|
||||
/**
|
||||
* What the proxy page should show.
|
||||
*
|
||||
* Deliberately does *not* answer "is there anything to render". That question is answered by
|
||||
* the list itself, in `hasRenderableItems`, because predicting it from beside the renderer is
|
||||
* how the two came to disagree. This decides only the things knowable without building a list:
|
||||
* direct mode, still-loading, and having no usable subscription to render from.
|
||||
*/
|
||||
export type ProxyListState =
|
||||
| { kind: 'direct' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'empty'; reason: ProxyEmptyStateReason }
|
||||
| { kind: 'render' }
|
||||
|
||||
if (isChainMode) {
|
||||
return proxyView.groups.some(
|
||||
(group) => group.type === 'Selector' || group.type === 'URLTest',
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'rule' || mode === 'script') {
|
||||
return proxyView.groups.some((group) => !group.hidden)
|
||||
}
|
||||
|
||||
return proxyView.global !== null
|
||||
}
|
||||
|
||||
export const resolveProxyEmptyState = ({
|
||||
export const resolveProxyListState = ({
|
||||
mode,
|
||||
isChainMode,
|
||||
profiles,
|
||||
isProfilesPending,
|
||||
proxyView,
|
||||
isProxyViewPending,
|
||||
isProxyViewError,
|
||||
runningMode,
|
||||
isRunningModePending,
|
||||
}: ProxyEmptyStateInput): ProxyEmptyStateReason | 'loading' | null => {
|
||||
if (mode === 'direct') return null
|
||||
}: ProxyListStateInput): ProxyListState => {
|
||||
if (mode === 'direct') return { kind: 'direct' }
|
||||
|
||||
if (isProfilesPending || isProxyViewPending || isRunningModePending) {
|
||||
return 'loading'
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
|
||||
if (profiles) {
|
||||
const subscriptions = (profiles.items ?? []).filter(isSubscriptionProfile)
|
||||
if (subscriptions.length === 0) return 'no-subscriptions'
|
||||
if (subscriptions.length === 0) {
|
||||
return { kind: 'empty', reason: 'no-subscriptions' }
|
||||
}
|
||||
|
||||
const hasActiveSubscription = subscriptions.some(
|
||||
(item) => item.uid === profiles.current,
|
||||
)
|
||||
if (!hasActiveSubscription) return 'inactive-subscription'
|
||||
if (!hasActiveSubscription) {
|
||||
return { kind: 'empty', reason: 'inactive-subscription' }
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRenderableProxyContent(proxyView, mode, isChainMode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (runningMode === 'NotRunning' || isProxyViewError) {
|
||||
return 'core-unavailable'
|
||||
}
|
||||
|
||||
return 'no-proxy-info'
|
||||
return { kind: 'render' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a list that turned out to be empty is empty.
|
||||
*
|
||||
* Only reachable once the renderer has built its list and found nothing, so it explains an
|
||||
* observed emptiness rather than predicting one.
|
||||
*/
|
||||
export const resolveEmptyListReason = ({
|
||||
runningMode,
|
||||
isProxyViewError,
|
||||
}: {
|
||||
runningMode?: string
|
||||
isProxyViewError: boolean
|
||||
}): ProxyEmptyStateReason =>
|
||||
runningMode === 'NotRunning' || isProxyViewError
|
||||
? 'core-unavailable'
|
||||
: 'no-proxy-info'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import { useAppRefreshers } from '@/providers/app-data-context'
|
||||
import { openLogsDir, restartCore } from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
@@ -25,7 +26,7 @@ export const ProxyEmptyState = ({ reason }: Props) => {
|
||||
setIsRestarting(true)
|
||||
try {
|
||||
await restartCore()
|
||||
await Promise.all([refreshProxy(), revalidateQuery(['getRunningMode'])])
|
||||
await Promise.all([refreshProxy(), revalidateQuery(runStateQueryKey)])
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
} finally {
|
||||
|
||||
@@ -32,13 +32,20 @@ import {
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
import { ProxyEmptyState } from './proxy-empty-state'
|
||||
import { resolveProxyEmptyState } from './proxy-empty-state-model'
|
||||
import {
|
||||
resolveEmptyListReason,
|
||||
resolveProxyListState,
|
||||
} from './proxy-empty-state-model'
|
||||
import {
|
||||
DEFAULT_HOVER_DELAY,
|
||||
ProxyGroupNavigator,
|
||||
} from './proxy-group-navigator'
|
||||
import { ProxyRender } from './proxy-render'
|
||||
import { type IRenderItem, useRenderList } from './use-render-list'
|
||||
import {
|
||||
hasRenderableItems,
|
||||
type IRenderItem,
|
||||
useRenderList,
|
||||
} from './use-render-list'
|
||||
|
||||
const ProxyGroupsChain = lazy(() =>
|
||||
import('./proxy-groups-chain').then((m) => ({
|
||||
@@ -58,6 +65,22 @@ interface Props {
|
||||
chainConfigData?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state to draw when the render list turns out to contain nothing.
|
||||
*
|
||||
* Shared by both list components so the observation and its explanation stay together.
|
||||
*/
|
||||
function useEmptyRenderList() {
|
||||
const { isProxyViewError } = useProxiesData()
|
||||
const { runningMode } = useSystemData()
|
||||
|
||||
return (
|
||||
<ProxyEmptyState
|
||||
reason={resolveEmptyListReason({ runningMode, isProxyViewError })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useProxyRenderState(
|
||||
mode: string,
|
||||
isChainMode: boolean,
|
||||
@@ -78,16 +101,6 @@ function useProxyRenderState(
|
||||
[activeSelectedGroup, isChainMode, mode],
|
||||
)
|
||||
|
||||
const getGroupHeadState = useCallback(
|
||||
(groupName: string) => {
|
||||
const headItem = renderList.find(
|
||||
(item) => item.type === 1 && item.group?.name === groupName,
|
||||
)
|
||||
return headItem?.headState
|
||||
},
|
||||
[renderList],
|
||||
)
|
||||
|
||||
const timeout = verge?.default_latency_timeout || 10000
|
||||
|
||||
// 测全部延迟
|
||||
@@ -120,10 +133,8 @@ function useProxyRenderState(
|
||||
} catch (error) {
|
||||
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
|
||||
} finally {
|
||||
const headState = getGroupHeadState(groupName)
|
||||
if (headState?.sortType === 1) {
|
||||
onHeadState(groupName, { sortType: headState.sortType })
|
||||
}
|
||||
// Re-sorting is no longer poked from here: the delay store announces that the test
|
||||
// settled and the render list recomputes from that.
|
||||
onProxies()
|
||||
}
|
||||
}),
|
||||
@@ -202,6 +213,7 @@ function ChainProxyGroups(props: {
|
||||
getScrollPosition,
|
||||
saveScrollPosition,
|
||||
} = useProxyRenderState(mode, true, activeSelectedGroup)
|
||||
const emptyList = useEmptyRenderList()
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const scrollTopRef = useRef(0)
|
||||
@@ -323,6 +335,9 @@ function ChainProxyGroups(props: {
|
||||
}
|
||||
})
|
||||
|
||||
// The list is built; whether it holds anything is now an observation, not a guess.
|
||||
if (!hasRenderableItems(renderList)) return emptyList
|
||||
|
||||
return (
|
||||
<Suspense fallback={<BaseLoading />}>
|
||||
<ProxyGroupsChain
|
||||
@@ -359,6 +374,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
getScrollPosition,
|
||||
saveScrollPosition,
|
||||
} = useProxyRenderState(mode, false, null)
|
||||
const emptyList = useEmptyRenderList()
|
||||
const renderFirstRef = useRef(true)
|
||||
// 恢复滚动位置期间设为 true,避免程序化滚动触发的 scroll 事件把中间值写回存储
|
||||
const isRestoringRef = useRef(false)
|
||||
@@ -573,6 +589,9 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
[handleChangeProxy, handleCheckAll, onHeadState, handleLocation],
|
||||
)
|
||||
|
||||
// The list is built; whether it holds anything is now an observation, not a guess.
|
||||
if (!hasRenderableItems(renderList)) return emptyList
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100%' }}>
|
||||
<StickyVirtualList
|
||||
@@ -602,36 +621,30 @@ function NormalProxyGroups(props: { mode: string }) {
|
||||
export const ProxyGroups = (props: Props) => {
|
||||
const { mode, isChainMode = false, chainConfigData } = props
|
||||
const { profiles, isLoading: isProfilesLoading } = useProfiles()
|
||||
const { proxyView, isProxyViewPending, isProxyViewError } = useProxiesData()
|
||||
const { runningMode, isRunningModePending } = useSystemData()
|
||||
const { isProxyViewPending } = useProxiesData()
|
||||
const { isRunningModePending } = useSystemData()
|
||||
|
||||
const emptyState = resolveProxyEmptyState({
|
||||
const listState = resolveProxyListState({
|
||||
mode,
|
||||
isChainMode,
|
||||
profiles,
|
||||
isProfilesPending: !profiles && isProfilesLoading,
|
||||
proxyView,
|
||||
isProxyViewPending,
|
||||
isProxyViewError,
|
||||
runningMode,
|
||||
isRunningModePending,
|
||||
})
|
||||
|
||||
if (mode === 'direct') {
|
||||
return <BaseEmpty textKey="proxies.page.messages.directMode" />
|
||||
switch (listState.kind) {
|
||||
case 'direct':
|
||||
return <BaseEmpty textKey="proxies.page.messages.directMode" />
|
||||
case 'loading':
|
||||
return <BaseLoading />
|
||||
case 'empty':
|
||||
return <ProxyEmptyState reason={listState.reason} />
|
||||
case 'render':
|
||||
// Whether there is anything to draw is the list's own answer, given below.
|
||||
return isChainMode ? (
|
||||
<ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
|
||||
) : (
|
||||
<NormalProxyGroups mode={mode} />
|
||||
)
|
||||
}
|
||||
|
||||
if (emptyState === 'loading') {
|
||||
return <BaseLoading />
|
||||
}
|
||||
|
||||
if (emptyState) {
|
||||
return <ProxyEmptyState reason={emptyState} />
|
||||
}
|
||||
|
||||
if (isChainMode) {
|
||||
return <ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
|
||||
}
|
||||
|
||||
return <NormalProxyGroups mode={mode} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import delayManager from '@/services/delay'
|
||||
import { memberDetails } from '@/types/proxy-view'
|
||||
import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
|
||||
import { compileStringMatcher } from '@/utils/search-matcher'
|
||||
|
||||
import type { ResolvedMemberOccurrence } from './use-render-list'
|
||||
@@ -105,31 +106,16 @@ function sortProxies(
|
||||
const effectiveTimeout =
|
||||
typeof latencyTimeout === 'number' && latencyTimeout > 0
|
||||
? latencyTimeout
|
||||
: 10000
|
||||
: DEFAULT_DELAY_TIMEOUT
|
||||
|
||||
if (sortType === 1) {
|
||||
const categorizeDelay = (delay: number): [number, number] => {
|
||||
if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER]
|
||||
if (delay > 1e5) return [4, delay]
|
||||
if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) {
|
||||
return [3, delay || effectiveTimeout]
|
||||
}
|
||||
if (delay < 0) {
|
||||
// sentinel delays (-1, -2, etc.) should always sort after real measurements
|
||||
return [5, Number.MAX_SAFE_INTEGER]
|
||||
}
|
||||
return [0, delay]
|
||||
}
|
||||
|
||||
list.sort((a, b) => {
|
||||
const ad = delayManager.getDelayFix(a.member, groupName)
|
||||
const bd = delayManager.getDelayFix(b.member, groupName)
|
||||
const [ar, av] = categorizeDelay(ad)
|
||||
const [br, bv] = categorizeDelay(bd)
|
||||
|
||||
if (ar !== br) return ar - br
|
||||
return av - bv
|
||||
})
|
||||
list.sort((a, b) =>
|
||||
compareByDelay(
|
||||
delayManager.getDelayFix(a.member, groupName),
|
||||
delayManager.getDelayFix(b.member, groupName),
|
||||
effectiveTimeout,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { hasRenderableItems, type IRenderItem } from './use-render-list'
|
||||
|
||||
const group = (hidden = false) =>
|
||||
({ name: 'Proxies', hidden }) as unknown as IRenderItem['group']
|
||||
|
||||
/** A group header row. On its own it is only content when the group is visible. */
|
||||
const header = (hidden = false): IRenderItem => ({
|
||||
type: 0,
|
||||
key: 'header',
|
||||
group: group(hidden),
|
||||
})
|
||||
|
||||
/** A member row — in chain mode the list is made entirely of these. */
|
||||
const member = (key: string): IRenderItem => ({
|
||||
type: 2,
|
||||
key,
|
||||
group: group(),
|
||||
})
|
||||
|
||||
/** A column of members, which is how the chain list renders at column counts above one. */
|
||||
const memberColumn = (key: string): IRenderItem => ({
|
||||
type: 4,
|
||||
key,
|
||||
group: group(),
|
||||
})
|
||||
|
||||
describe('hasRenderableItems', () => {
|
||||
test('an empty list has nothing to show', () => {
|
||||
expect(hasRenderableItems([])).toBe(false)
|
||||
})
|
||||
|
||||
test('chain mode is answered by the chain list itself', () => {
|
||||
// This is the case the old prediction got wrong in both directions: it asked whether any
|
||||
// Selector or URLTest group existed, which is unrelated to what the chain list is built
|
||||
// from. Here there are rows, so there is content, whatever groups happen to exist.
|
||||
expect(hasRenderableItems([member('chain:a')])).toBe(true)
|
||||
expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true)
|
||||
})
|
||||
|
||||
test('a visible group counts even before it is expanded', () => {
|
||||
expect(hasRenderableItems([header()])).toBe(true)
|
||||
})
|
||||
|
||||
test('hidden groups alone are not content', () => {
|
||||
expect(hasRenderableItems([header(true), header(true)])).toBe(false)
|
||||
})
|
||||
|
||||
test('one visible group among hidden ones is enough', () => {
|
||||
expect(hasRenderableItems([header(true), header(), header(true)])).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('rows inside a hidden but expanded group are still rows on screen', () => {
|
||||
// Truthfulness over tidiness: the user can see these, so calling the page empty would be
|
||||
// a lie. The old predicate said "empty" here.
|
||||
expect(hasRenderableItems([header(true), member('member:a')])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useRuntimeConfig } from '@/hooks/use-clash'
|
||||
import { useGroupsDelays } from '@/hooks/use-group-delays'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
|
||||
import delayManager from '@/services/delay'
|
||||
import delayManager, { type DelaySnapshot } from '@/services/delay'
|
||||
import {
|
||||
isInteractableMember,
|
||||
resolveMember,
|
||||
@@ -42,12 +43,30 @@ export interface IRenderItem {
|
||||
testUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the list about to be drawn contains anything a user would call content.
|
||||
*
|
||||
* Derived from the list itself rather than predicted beside it. The prediction that used to
|
||||
* live in the empty-state model asked a different question in chain mode — whether any
|
||||
* Selector or URLTest group existed — which is unrelated to what the chain list is actually
|
||||
* built from, so both a false "empty" and a false "not empty" were reachable.
|
||||
*
|
||||
* A group header alone counts only when the group is visible; every other row is content by
|
||||
* definition, including the members of a group that is hidden but expanded.
|
||||
*/
|
||||
export const hasRenderableItems = (
|
||||
renderList: readonly IRenderItem[],
|
||||
): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden)
|
||||
|
||||
type GroupCache = {
|
||||
now: string | undefined
|
||||
members: ProxyGroupView['members']
|
||||
headState: HeadState
|
||||
col: number
|
||||
latencyTimeout: number | undefined
|
||||
/// This group's own delays. Compared by identity so that a test settling in one group
|
||||
/// does not throw away every other group's sorted order.
|
||||
delays: DelaySnapshot | undefined
|
||||
items: IRenderItem[]
|
||||
}
|
||||
|
||||
@@ -188,6 +207,19 @@ export const useRenderList = (
|
||||
verge?.default_latency_timeout,
|
||||
])
|
||||
|
||||
// Every group this list draws, so a test settling in any of them re-sorts that group.
|
||||
const renderedGroupNames = useMemo(() => {
|
||||
if (!proxyView) return []
|
||||
if (isChainMode)
|
||||
return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP]
|
||||
return mode === 'rule' || mode === 'script'
|
||||
? proxyView.groups.map(({ name }) => name)
|
||||
: proxyView.global
|
||||
? [proxyView.global.name]
|
||||
: []
|
||||
}, [isChainMode, mode, proxyView, selectedGroup])
|
||||
const groupDelays = useGroupsDelays(renderedGroupNames)
|
||||
|
||||
const groupCacheRef = useRef<Map<string, GroupCache>>(new Map())
|
||||
const prevListRef = useRef<IRenderItem[]>([])
|
||||
|
||||
@@ -244,7 +276,8 @@ export const useRenderList = (
|
||||
cached.members === group.members &&
|
||||
cached.headState === headState &&
|
||||
cached.col === col &&
|
||||
cached.latencyTimeout === latencyTimeout
|
||||
cached.latencyTimeout === latencyTimeout &&
|
||||
cached.delays === groupDelays.get(group.name)
|
||||
) {
|
||||
return cached.items
|
||||
}
|
||||
@@ -309,6 +342,7 @@ export const useRenderList = (
|
||||
headState,
|
||||
col,
|
||||
latencyTimeout,
|
||||
delays: groupDelays.get(group.name),
|
||||
items: ret,
|
||||
})
|
||||
return ret
|
||||
@@ -325,6 +359,7 @@ export const useRenderList = (
|
||||
}, [
|
||||
chainOccurrences,
|
||||
col,
|
||||
groupDelays,
|
||||
headStates,
|
||||
isChainMode,
|
||||
latencyTimeout,
|
||||
|
||||
@@ -135,8 +135,9 @@ const ProxyControlSwitches = ({
|
||||
const { uninstallServiceAndStartSidecar } = useServiceUninstaller()
|
||||
const { indicator: systemProxyIndicator, toggleSystemProxy } =
|
||||
useSystemProxyState()
|
||||
const { isServiceInstallReady, isTunModeAvailable, mutateSystemState } =
|
||||
useSystemState()
|
||||
const { runState, isTunModeAvailable, mutateSystemState } = useSystemState()
|
||||
// Offer to uninstall only a service that is actually there and working.
|
||||
const isServiceInstallReady = runState.serviceUsable
|
||||
|
||||
const sysproxyRef = useRef<DialogRef>(null)
|
||||
const tunRef = useRef<DialogRef>(null)
|
||||
|
||||
@@ -2,18 +2,16 @@ import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { LanguageRounded } from '@mui/icons-material'
|
||||
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
|
||||
import { UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseLoading } from '@/components/base'
|
||||
import { useIconCache } from '@/hooks/use-icon-cache'
|
||||
import { useListen } from '@/hooks/use-listen'
|
||||
import { cmdTestDelay } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
import { TestBox } from './test-box'
|
||||
|
||||
@@ -47,7 +45,6 @@ export const TestItem = ({
|
||||
const [delay, setDelay] = useState(-1)
|
||||
const { uid, name, icon, url } = itemData
|
||||
const iconCachePath = useIconCache({ icon, cacheKey: uid })
|
||||
const { addListener } = useListen()
|
||||
|
||||
const onDelay = useCallback(async () => {
|
||||
setDelay(-2)
|
||||
@@ -74,29 +71,10 @@ export const TestItem = ({
|
||||
{ label: t('shared.actions.delete'), handler: onDelete },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
let unlistenFn: UnlistenFn | null = null
|
||||
|
||||
const setupListener = async () => {
|
||||
if (unlistenFn) {
|
||||
unlistenFn()
|
||||
}
|
||||
unlistenFn = await addListener('verge://test-all', () => {
|
||||
onDelay()
|
||||
})
|
||||
}
|
||||
|
||||
setupListener()
|
||||
|
||||
return () => {
|
||||
if (unlistenFn) {
|
||||
debugLog(
|
||||
`TestItem for ${id} unmounting or url changed, cleaning up test-all listener.`,
|
||||
)
|
||||
unlistenFn()
|
||||
}
|
||||
}
|
||||
}, [url, addListener, onDelay, id])
|
||||
useEffect(
|
||||
() => subscribeVergeEvents({ 'verge://test-all': () => onDelay() }),
|
||||
[url, onDelay],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
|
||||
import delayManager, { type DelaySnapshot } from '@/services/delay'
|
||||
|
||||
const NO_DELAYS: DelaySnapshot = { of: () => -1 }
|
||||
|
||||
/**
|
||||
* The delays a list sorts by, as a value it can depend on.
|
||||
*
|
||||
* Delays live in a module-level store outside React, so a memo that reads them has nothing
|
||||
* React can see and goes stale — which is why the two places that sorted by delay each grew
|
||||
* their own way of forcing a recompute, and why both missed the single-proxy path.
|
||||
*
|
||||
* `useSyncExternalStore` rather than an effect plus state: the value is read during render
|
||||
* while the store notifies from an animation frame, so a settle landing between the two would
|
||||
* otherwise be dropped with nothing left to re-trigger it. It also keeps the identity stable
|
||||
* between settles, which a snapshot rebuilt per render would not — that would re-sort on
|
||||
* every render, which is worse than the hack it replaces.
|
||||
*
|
||||
* The store announces when a test *settles*, not on every measurement: a list re-sorting on
|
||||
* each result would reshuffle for the length of a batch, moving rows out from under the
|
||||
* pointer. Per-proxy displays stay live on their own subscription and are unaffected.
|
||||
*/
|
||||
export const useGroupDelays = (group: string | null): DelaySnapshot => {
|
||||
const subscribe = useCallback(
|
||||
(onSettle: () => void) =>
|
||||
group ? delayManager.addGroupListener(group, onSettle) : () => {},
|
||||
[group],
|
||||
)
|
||||
const read = useCallback(
|
||||
() => (group ? delayManager.groupDelays(group) : NO_DELAYS),
|
||||
[group],
|
||||
)
|
||||
|
||||
return useSyncExternalStore(subscribe, read, read)
|
||||
}
|
||||
|
||||
/**
|
||||
* The delays for several groups at once, keyed by group name.
|
||||
*
|
||||
* The map's identity changes whenever any of them settles, but each group's own entry keeps
|
||||
* its identity unless that group settled — so a per-group cache is not thrown away wholesale
|
||||
* because a neighbouring group finished a test.
|
||||
*/
|
||||
export const useGroupsDelays = (
|
||||
groups: readonly string[],
|
||||
): ReadonlyMap<string, DelaySnapshot> => {
|
||||
// Joined so the subscription tracks membership rather than the array identity, which a
|
||||
// list rebuilds on every render.
|
||||
const groupKey = groups.join(' ')
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onSettle: () => void) => {
|
||||
const names = groupKey ? groupKey.split(' ') : []
|
||||
const unsubscribes = names.map((name) =>
|
||||
delayManager.addGroupListener(name, onSettle),
|
||||
)
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) unsubscribe()
|
||||
}
|
||||
},
|
||||
[groupKey],
|
||||
)
|
||||
|
||||
const read = useCallback(
|
||||
() => delayManager.groupsDelays(groupKey),
|
||||
[groupKey],
|
||||
)
|
||||
|
||||
return useSyncExternalStore(subscribe, read, read)
|
||||
}
|
||||
+37
-136
@@ -1,162 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getRunningMode,
|
||||
getServiceInstallState,
|
||||
isAdmin,
|
||||
getRuntimeState,
|
||||
type RunState,
|
||||
type RunningMode,
|
||||
type ServiceInstallState,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
|
||||
import { useVerge } from './use-verge'
|
||||
import { useVisibility } from './use-visibility'
|
||||
|
||||
export interface SystemState {
|
||||
runningMode: RunningMode
|
||||
isAdminMode: boolean
|
||||
isServiceInstallReady: boolean
|
||||
serviceInstallState: ServiceInstallState | null
|
||||
}
|
||||
|
||||
const defaultSystemState = {
|
||||
runningMode: 'Sidecar',
|
||||
isAdminMode: false,
|
||||
isServiceInstallReady: false,
|
||||
serviceInstallState: null,
|
||||
} as SystemState
|
||||
|
||||
export const fetchSystemState = async (): Promise<SystemState> => {
|
||||
const [runningMode, isAdminMode, serviceInstallState] = await Promise.all([
|
||||
getRunningMode(),
|
||||
isAdmin(),
|
||||
getServiceInstallState().catch(() => null),
|
||||
])
|
||||
return {
|
||||
runningMode,
|
||||
isAdminMode,
|
||||
isServiceInstallReady: serviceInstallState === 'ready',
|
||||
serviceInstallState,
|
||||
}
|
||||
}
|
||||
|
||||
const isServiceChoicePending = (
|
||||
state: ServiceInstallState | null,
|
||||
serviceInstallReady: boolean,
|
||||
queryFailed: boolean,
|
||||
) =>
|
||||
queryFailed ||
|
||||
state === 'sidecarAllowed' ||
|
||||
!(state === 'notInstalled' || (state === 'ready' && serviceInstallReady))
|
||||
|
||||
// Grace period for service initialization during startup
|
||||
const STARTUP_GRACE_MS = 10_000
|
||||
export const runStateQueryKey = ['getRuntimeState'] as const
|
||||
|
||||
/**
|
||||
* 自定义 hook 用于获取系统运行状态
|
||||
* 包括运行模式、管理员状态、系统服务是否可用
|
||||
* Until the first snapshot arrives, assume the least capable environment: no service, no
|
||||
* elevation, nothing asked of the user. Guessing "ready" here would flash a usable TUN toggle.
|
||||
*/
|
||||
const unknownRunState: RunState = {
|
||||
mode: 'NotRunning',
|
||||
service: 'unknown',
|
||||
serviceUnavailableReason: null,
|
||||
pendingAction: null,
|
||||
sidecarAllowed: false,
|
||||
isAdmin: false,
|
||||
opInFlight: false,
|
||||
serviceUsable: false,
|
||||
tunCapable: false,
|
||||
serviceNeedsAttention: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* The Run State: how the core is running and what backs it.
|
||||
*
|
||||
* One query key, kept fresh by `verge://run-state-changed` rather than polling. Every derived
|
||||
* answer is computed in Rust and travels with the snapshot, so there is exactly one definition
|
||||
* of "TUN can work" in the app.
|
||||
*/
|
||||
export function useSystemState() {
|
||||
const pageVisible = useVisibility()
|
||||
const [isStartingUp, setIsStartingUp] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsStartingUp(false), STARTUP_GRACE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
data: systemState = defaultSystemState,
|
||||
data: runState = unknownRunState,
|
||||
refetch: mutateSystemState,
|
||||
isLoading,
|
||||
error: systemStateError,
|
||||
} = useQuery({
|
||||
queryKey: ['getSystemState'],
|
||||
queryFn: fetchSystemState,
|
||||
refetchInterval: pageVisible ? (isStartingUp ? 2000 : 30000) : false,
|
||||
queryKey: runStateQueryKey,
|
||||
queryFn: getRuntimeState,
|
||||
// A safety net only: transitions are pushed, so this is not the primary path.
|
||||
refetchInterval: pageVisible ? 30000 : false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
|
||||
const isSidecarMode = systemState.runningMode === 'Sidecar'
|
||||
const isServiceMode = systemState.runningMode === 'Service'
|
||||
const isTunModeAvailable =
|
||||
systemState.isAdminMode || systemState.isServiceInstallReady
|
||||
const serviceChoicePending = isServiceChoicePending(
|
||||
systemState.serviceInstallState,
|
||||
systemState.isServiceInstallReady,
|
||||
systemStateError !== undefined,
|
||||
)
|
||||
|
||||
return {
|
||||
runningMode: systemState.runningMode,
|
||||
isAdminMode: systemState.isAdminMode,
|
||||
isServiceInstallReady: systemState.isServiceInstallReady,
|
||||
isSidecarMode,
|
||||
isServiceMode,
|
||||
isTunModeAvailable,
|
||||
serviceChoicePending,
|
||||
runState,
|
||||
runningMode: runState.mode as RunningMode,
|
||||
isAdminMode: runState.isAdmin,
|
||||
isSidecarMode: runState.mode === 'Sidecar',
|
||||
isServiceMode: runState.mode === 'Service',
|
||||
isTunModeAvailable: runState.tunCapable,
|
||||
serviceNeedsAttention: runState.serviceNeedsAttention,
|
||||
mutateSystemState,
|
||||
isLoading,
|
||||
isStartingUp,
|
||||
}
|
||||
}
|
||||
|
||||
export function useTunAvailabilityGuard() {
|
||||
const { verge, patchVerge } = useVerge()
|
||||
const { isTunModeAvailable, serviceChoicePending, isLoading, isStartingUp } =
|
||||
useSystemState()
|
||||
const disablingTunRef = useRef(false)
|
||||
const cooldownTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const enable_tun_mode = verge?.enable_tun_mode
|
||||
|
||||
useEffect(() => {
|
||||
if (enable_tun_mode === undefined) return
|
||||
|
||||
if (
|
||||
!disablingTunRef.current &&
|
||||
enable_tun_mode &&
|
||||
!isTunModeAvailable &&
|
||||
!serviceChoicePending &&
|
||||
!isLoading &&
|
||||
!isStartingUp
|
||||
) {
|
||||
disablingTunRef.current = true
|
||||
patchVerge({ enable_tun_mode: false })
|
||||
.then(() => {
|
||||
showNotice.info(
|
||||
'settings.sections.system.notifications.tunMode.autoDisabled',
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[useVerge] 自动关闭TUN模式失败:', err)
|
||||
showNotice.error(
|
||||
'settings.sections.system.notifications.tunMode.autoDisableFailed',
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
// 避免 verge 数据更新不及时导致重复执行关闭 Tun 模式
|
||||
cooldownTimerRef.current = setTimeout(() => {
|
||||
disablingTunRef.current = false
|
||||
cooldownTimerRef.current = null
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (cooldownTimerRef.current != null) {
|
||||
clearTimeout(cooldownTimerRef.current)
|
||||
cooldownTimerRef.current = null
|
||||
disablingTunRef.current = false
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enable_tun_mode,
|
||||
isTunModeAvailable,
|
||||
serviceChoicePending,
|
||||
patchVerge,
|
||||
isLoading,
|
||||
isStartingUp,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
WindowResizeHandles,
|
||||
} from '@/components/layout/window-controller'
|
||||
import { useI18n } from '@/hooks/use-i18n'
|
||||
import { useTunAvailabilityGuard } from '@/hooks/use-system-state'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useWindowDecorations } from '@/hooks/use-window'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
@@ -123,8 +122,6 @@ const Layout = () => {
|
||||
const navigate = useNavigate()
|
||||
const themeReady = useMemo(() => Boolean(theme), [theme])
|
||||
|
||||
useTunAvailabilityGuard()
|
||||
|
||||
const [menuUnlocked, setMenuUnlocked] = useState(false)
|
||||
const [menuContextPosition, setMenuContextPosition] =
|
||||
useState<MenuContextPosition | null>(null)
|
||||
|
||||
@@ -1,46 +1,20 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useListen } from '@/hooks/use-listen'
|
||||
import { revalidateQueries } from '@/services/query-client'
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import type { RunState } from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { revalidateQueries, setCacheDataAsync } from '@/services/query-client'
|
||||
|
||||
export const useLayoutEvents = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
const { addListener } = useListen()
|
||||
|
||||
useEffect(() => {
|
||||
const unlisteners: Array<() => void> = []
|
||||
let disposed = false
|
||||
const revalidateKeys = (keys: readonly string[]) => {
|
||||
void revalidateQueries(keys.map((key) => [key]))
|
||||
}
|
||||
|
||||
const register = (
|
||||
maybeUnlisten: void | (() => void) | Promise<void | (() => void)>,
|
||||
) => {
|
||||
if (!maybeUnlisten) return
|
||||
|
||||
if (typeof maybeUnlisten === 'function') {
|
||||
unlisteners.push(maybeUnlisten)
|
||||
return
|
||||
}
|
||||
|
||||
maybeUnlisten
|
||||
.then((unlisten) => {
|
||||
if (!unlisten) return
|
||||
if (disposed) {
|
||||
unlisten()
|
||||
} else {
|
||||
unlisteners.push(unlisten)
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error('[Event Listener] Registration failed:', error),
|
||||
)
|
||||
}
|
||||
|
||||
register(
|
||||
addListener('verge://refresh-clash-config', () => {
|
||||
return subscribeVergeEvents({
|
||||
'verge://refresh-clash-config': () => {
|
||||
revalidateKeys([
|
||||
'getProxyView',
|
||||
'getVersion',
|
||||
@@ -51,47 +25,16 @@ export const useLayoutEvents = (
|
||||
'getRules',
|
||||
'getRuleProviders',
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
register(
|
||||
addListener('verge://refresh-verge-config', () => {
|
||||
revalidateKeys([
|
||||
'getVergeConfig',
|
||||
'getSystemProxy',
|
||||
'getAutotemProxy',
|
||||
'getRunningMode',
|
||||
'getSystemState',
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
register(
|
||||
addListener('verge://notice-message', ({ payload }) =>
|
||||
handleNotice(payload as [string, string]),
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
const errors: Error[] = []
|
||||
|
||||
unlisteners.forEach((unlisten) => {
|
||||
try {
|
||||
unlisten()
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
`[Event Listener] Encountered ${errors.length} errors during cleanup:`,
|
||||
errors,
|
||||
)
|
||||
}
|
||||
|
||||
unlisteners.length = 0
|
||||
}
|
||||
}, [addListener, handleNotice])
|
||||
},
|
||||
'verge://refresh-verge-config': () => {
|
||||
revalidateKeys(['getVergeConfig', 'getSystemProxy', 'getAutotemProxy'])
|
||||
},
|
||||
// The Run State is pushed, not polled: every transition carries the new snapshot, so it
|
||||
// is written straight into the cache instead of triggering a fetch.
|
||||
'verge://run-state-changed': (payload) => {
|
||||
void setCacheDataAsync<RunState>(runStateQueryKey, payload)
|
||||
},
|
||||
'verge://notice-message': handleNotice,
|
||||
})
|
||||
}, [handleNotice])
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ export const handleNoticeMessage = (
|
||||
showNotice.error(msg)
|
||||
},
|
||||
'set_config::error': () => showNotice.error(msg),
|
||||
'tun_mode::auto_disabled': () =>
|
||||
showNotice.info(
|
||||
'settings.sections.system.notifications.tunMode.autoDisabled',
|
||||
),
|
||||
'tun_mode::auto_disable_failed': () =>
|
||||
showNotice.error(
|
||||
'settings.sections.system.notifications.tunMode.autoDisableFailed',
|
||||
),
|
||||
'app_restart::core_stop_failed': () =>
|
||||
showNotice.error('layout.feedback.errors.restartCoreStopFailed'),
|
||||
'app_quit::core_stop_failed': () =>
|
||||
|
||||
+27
-46
@@ -25,7 +25,7 @@ import {
|
||||
TextSnippetOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
|
||||
import { listen, TauriEvent } from '@tauri-apps/api/event'
|
||||
import { TauriEvent } from '@tauri-apps/api/event'
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useLockFn } from 'ahooks'
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
reorderProfile,
|
||||
updateProfile,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
fetchCacheData,
|
||||
@@ -584,52 +585,32 @@ const ProfilePage = () => {
|
||||
[setLoadingCache],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let unlisteners: Array<() => void> = []
|
||||
|
||||
Promise.allSettled([
|
||||
listen<{ uid?: string }>('profile-update-started', ({ payload }) => {
|
||||
if (payload.uid) setLoadingProfiles([payload.uid], true)
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeVergeEvents({
|
||||
'profile-update-started': ({ uid }) => {
|
||||
if (uid) setLoadingProfiles([uid], true)
|
||||
},
|
||||
'profile-update-completed': ({ uid }) => {
|
||||
if (!uid) return
|
||||
setLoadingProfiles([uid], false)
|
||||
setCompletedUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
void mutateProfiles()
|
||||
},
|
||||
'verge://timer-updated': (uid) => {
|
||||
setTimerUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
},
|
||||
}),
|
||||
listen<{ uid?: string }>('profile-update-completed', ({ payload }) => {
|
||||
const { uid } = payload
|
||||
if (!uid) return
|
||||
setLoadingProfiles([uid], false)
|
||||
setCompletedUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
void mutateProfiles()
|
||||
}),
|
||||
listen<string>('verge://timer-updated', ({ payload: uid }) => {
|
||||
setTimerUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
}),
|
||||
]).then((results) => {
|
||||
const registeredUnlisteners = results.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : [],
|
||||
)
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'rejected') console.error(result.reason)
|
||||
})
|
||||
|
||||
if (disposed) {
|
||||
registeredUnlisteners.forEach((unlisten) => unlisten())
|
||||
} else {
|
||||
unlisteners = registeredUnlisteners
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unlisteners.forEach((unlisten) => unlisten())
|
||||
}
|
||||
}, [mutateProfiles, setLoadingProfiles])
|
||||
[mutateProfiles, setLoadingProfiles],
|
||||
)
|
||||
|
||||
const runProfileUpdates = useCallback(
|
||||
async (uids: string[]) => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Stub the Tauri boundary and nothing above it.
|
||||
*
|
||||
* Everything the provider mounts — SWR queries, the config hooks, the event subscription,
|
||||
* the mihomo plugin — goes through `invoke` or `listen`, so replacing just those two leaves
|
||||
* all the real application code running. A test that stubbed the hooks instead could not
|
||||
* catch what this one exists to catch.
|
||||
*/
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(async () => undefined),
|
||||
Channel: class {},
|
||||
}))
|
||||
vi.mock('@tauri-apps/api/event', () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
TauriEvent: {},
|
||||
}))
|
||||
|
||||
import { AppDataProvider } from './app-data-provider'
|
||||
|
||||
beforeEach(() => {
|
||||
// The provider's queries poll; keep them from firing during the assertion.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Mounting the provider is the whole test.
|
||||
*
|
||||
* A hook inside `AppDataProvider` that reads a context `AppDataProvider` itself supplies
|
||||
* throws during render and blanks the entire UI. That shipped once. `typecheck`, `lint`,
|
||||
* `knip` and `web:build` all passed with it present, and so did an independent review —
|
||||
* nothing but rendering can see it.
|
||||
*/
|
||||
test('AppDataProvider mounts and renders its children', () => {
|
||||
render(
|
||||
<AppDataProvider>
|
||||
<span>child rendered</span>
|
||||
</AppDataProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('child rendered')).toBeDefined()
|
||||
})
|
||||
|
||||
test('AppDataProvider does not consume a context it provides', () => {
|
||||
// The failure mode is specific enough to name: `useCtx` throws
|
||||
// "<hook> must be used within AppDataProvider" when a provider reads its own context,
|
||||
// which is indistinguishable at the type level from a legitimate call site.
|
||||
const errors: unknown[] = []
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation((...args) => {
|
||||
errors.push(args)
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<AppDataProvider>
|
||||
<span>child rendered</span>
|
||||
</AppDataProvider>,
|
||||
),
|
||||
).not.toThrow()
|
||||
|
||||
const reported = errors.flat().join(' ')
|
||||
expect(reported).not.toContain('must be used within AppDataProvider')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
getBaseConfig,
|
||||
@@ -7,13 +6,15 @@ import {
|
||||
} from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import {
|
||||
getAppUptime,
|
||||
getProxyView,
|
||||
getRunningMode,
|
||||
getRuntimeState,
|
||||
getSystemProxy,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { revalidateQueries, useQuery } from '@/services/query-client'
|
||||
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
|
||||
|
||||
@@ -100,11 +101,13 @@ export const AppDataProvider = ({
|
||||
...TQ_DEFAULTS,
|
||||
})
|
||||
|
||||
const { data: runningMode, isPending: isRunningModePending } = useQuery({
|
||||
queryKey: ['getRunningMode'],
|
||||
queryFn: getRunningMode,
|
||||
// Same key as `useSystemState`, so this is the one Run State cache entry, not a second one.
|
||||
const { data: runState, isPending: isRunningModePending } = useQuery({
|
||||
queryKey: runStateQueryKey,
|
||||
queryFn: getRuntimeState,
|
||||
...TQ_DEFAULTS,
|
||||
})
|
||||
const runningMode = runState?.mode
|
||||
|
||||
const { data: uptimeData } = useQuery({
|
||||
queryKey: ['appUptime'],
|
||||
@@ -125,10 +128,7 @@ export const AppDataProvider = ({
|
||||
let lastProfileUpdateTime = 0
|
||||
let lastProxyUpdateTime = 0
|
||||
const refreshThrottle = 800
|
||||
const cleanupFns: Array<() => void> = []
|
||||
|
||||
const handleProfileChanged = (event: { payload: string }) => {
|
||||
const newProfileId = event.payload
|
||||
const handleProfileChanged = (newProfileId: string) => {
|
||||
const now = Date.now()
|
||||
if (
|
||||
lastProfileId === newProfileId &&
|
||||
@@ -152,49 +152,11 @@ export const AppDataProvider = ({
|
||||
void revalidateQueries([['getProfiles']])
|
||||
}
|
||||
|
||||
const initializeListeners = async () => {
|
||||
try {
|
||||
const unlistenProfile = await listen<string>(
|
||||
'profile-changed',
|
||||
handleProfileChanged,
|
||||
)
|
||||
cleanupFns.push(unlistenProfile)
|
||||
} catch (error) {
|
||||
console.error('[AppDataProvider] 监听 Profile 事件失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const unlistenProfiles = await listen(
|
||||
'verge://refresh-profiles',
|
||||
handleRefreshProfiles,
|
||||
)
|
||||
cleanupFns.push(unlistenProfiles)
|
||||
} catch (error) {
|
||||
console.error('[AppDataProvider] 监听 Profiles 刷新事件失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const unlistenProxy = await listen(
|
||||
'verge://refresh-proxy-config',
|
||||
handleRefreshProxy,
|
||||
)
|
||||
cleanupFns.push(unlistenProxy)
|
||||
} catch (error) {
|
||||
console.warn('[AppDataProvider] 设置 Tauri 事件监听器失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void initializeListeners()
|
||||
|
||||
return () => {
|
||||
cleanupFns.forEach((fn) => {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
console.error('[DataProvider] Cleanup error:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
return subscribeVergeEvents({
|
||||
'profile-changed': handleProfileChanged,
|
||||
'verge://refresh-profiles': handleRefreshProfiles,
|
||||
'verge://refresh-proxy-config': handleRefreshProxy,
|
||||
})
|
||||
}, [refreshProxy])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
@@ -238,14 +200,16 @@ export const AppDataProvider = ({
|
||||
[clashConfig, isClashConfigPending],
|
||||
)
|
||||
|
||||
const systemValue = useMemo(() => {
|
||||
const displayedMixedPort = resolveDisplayedMixedPort({
|
||||
live: clashConfig?.mixedPort,
|
||||
runtime: runtimeConfig?.['mixed-port'],
|
||||
selected: verge?.verge_mixed_port,
|
||||
merge: clashInfo?.mixed_port,
|
||||
})
|
||||
// Resolved from local sources rather than via useDisplayedMixedPort: that hook reads the
|
||||
// ClashConfig context, and this component is the one providing it.
|
||||
const displayedMixedPort = resolveDisplayedMixedPort({
|
||||
live: clashConfig?.mixedPort,
|
||||
runtime: runtimeConfig?.['mixed-port'],
|
||||
selected: verge?.verge_mixed_port,
|
||||
merge: clashInfo?.mixed_port,
|
||||
})
|
||||
|
||||
const systemValue = useMemo(() => {
|
||||
const calculateSystemProxyAddress = () => {
|
||||
if (!verge) return '-'
|
||||
|
||||
@@ -278,15 +242,7 @@ export const AppDataProvider = ({
|
||||
isRunningModePending,
|
||||
systemProxyAddress: calculateSystemProxyAddress(),
|
||||
}
|
||||
}, [
|
||||
sysproxy,
|
||||
runningMode,
|
||||
isRunningModePending,
|
||||
verge,
|
||||
clashConfig,
|
||||
runtimeConfig,
|
||||
clashInfo,
|
||||
])
|
||||
}, [sysproxy, runningMode, isRunningModePending, verge, displayedMixedPort])
|
||||
|
||||
const uptimeValue = useMemo(() => ({ uptime: uptimeData || 0 }), [uptimeData])
|
||||
|
||||
|
||||
+34
-24
@@ -360,8 +360,40 @@ export async function listLocalBackup() {
|
||||
// 获取当前运行模式
|
||||
export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning'
|
||||
|
||||
export const getRunningMode = async () => {
|
||||
return invoke<RunningMode>('get_running_mode')
|
||||
type ServiceHealth =
|
||||
| 'unknown'
|
||||
| 'ready'
|
||||
| 'notInstalled'
|
||||
| 'versionMismatch'
|
||||
| 'unavailable'
|
||||
|
||||
type PendingServiceAction =
|
||||
| 'install'
|
||||
| 'uninstall'
|
||||
| 'reinstall'
|
||||
| 'forceReinstall'
|
||||
|
||||
/**
|
||||
* How the core is running and what backs it, as one consistent snapshot.
|
||||
*
|
||||
* The derived answers travel with it — `tunCapable`, `serviceUsable`,
|
||||
* `serviceNeedsAttention` — so nothing here is recomputed from the raw fields.
|
||||
*/
|
||||
export interface RunState {
|
||||
mode: RunningMode
|
||||
service: ServiceHealth
|
||||
serviceUnavailableReason: string | null
|
||||
pendingAction: PendingServiceAction | null
|
||||
sidecarAllowed: boolean
|
||||
isAdmin: boolean
|
||||
opInFlight: boolean
|
||||
serviceUsable: boolean
|
||||
tunCapable: boolean
|
||||
serviceNeedsAttention: boolean
|
||||
}
|
||||
|
||||
export const getRuntimeState = async () => {
|
||||
return invoke<RunState>('get_runtime_state')
|
||||
}
|
||||
|
||||
// 获取应用运行时间
|
||||
@@ -379,19 +411,6 @@ export const uninstallService = async () => {
|
||||
return invoke<void>('uninstall_service')
|
||||
}
|
||||
|
||||
export type ServiceInstallState =
|
||||
| 'checking'
|
||||
| 'notInstalled'
|
||||
| 'installRequired'
|
||||
| 'ready'
|
||||
| 'needsReinstall'
|
||||
| 'sidecarAllowed'
|
||||
| 'unavailable'
|
||||
|
||||
export const getServiceInstallState = async () => {
|
||||
return invoke<ServiceInstallState>('get_service_install_state')
|
||||
}
|
||||
|
||||
export const reinstallService = async () => {
|
||||
return invoke<void>('reinstall_service')
|
||||
}
|
||||
@@ -408,15 +427,6 @@ export const entry_lightweight_mode = async () => {
|
||||
return invoke<void>('entry_lightweight_mode')
|
||||
}
|
||||
|
||||
export const isAdmin = async () => {
|
||||
try {
|
||||
return await invoke<boolean>('app_is_admin')
|
||||
} catch (error) {
|
||||
console.error('检查管理员权限失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNextUpdateTime(uid: string) {
|
||||
return invoke<number | null>('get_next_update_time', { uid })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* The delay API is the only thing stubbed. What is under test is when the manager announces
|
||||
* that a group has settled — the granularity that decides whether a sorted list re-orders
|
||||
* once per test or once per result.
|
||||
*/
|
||||
vi.mock('tauri-plugin-mihomo-api', () => ({
|
||||
delayProxyByName: vi.fn(async () => ({ delay: 120 })),
|
||||
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
|
||||
}))
|
||||
|
||||
import type { ResolvedProxyMember } from '@/types/proxy-view'
|
||||
|
||||
import delayManager from './delay'
|
||||
|
||||
const node = (name: string) =>
|
||||
({
|
||||
kind: 'node',
|
||||
ref: { kind: 'node', name, recordId: `r:${name}` },
|
||||
node: {
|
||||
recordId: `r:${name}`,
|
||||
name,
|
||||
history: [],
|
||||
source: { kind: 'core', proxyName: name },
|
||||
},
|
||||
}) as unknown as ResolvedProxyMember
|
||||
|
||||
/** The manager coalesces notifications onto a frame; give it one. */
|
||||
const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
let settles = 0
|
||||
let unsubscribe: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
settles = 0
|
||||
unsubscribe = delayManager.addGroupListener('g', () => {
|
||||
settles += 1
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
describe('group settle notifications', () => {
|
||||
test('a single test settles the group once', async () => {
|
||||
// The bug this fixes: a single-proxy retest updated that proxy's own display but never
|
||||
// told the list to re-sort, so a group sorted by delay kept a stale order.
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('a batch settles the group once, however many proxies it covered', async () => {
|
||||
// The granularity that matters. Announcing per result would re-sort continuously for
|
||||
// the length of the test, moving rows out from under the pointer — and the symptom
|
||||
// would be jumpy scrolling, which nothing else here could assert.
|
||||
const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`))
|
||||
|
||||
await delayManager.checkListDelay(proxies as never, 'g', 5000, 4)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('an unsubscribed listener stops being called', async () => {
|
||||
unsubscribe()
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
})
|
||||
|
||||
test('two listeners on one group both hear it', async () => {
|
||||
// Group listeners used to be one-per-group, so a second view silently replaced the
|
||||
// first one's subscription.
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('a single test inside a running batch waits for the batch', async () => {
|
||||
// The window the batch counter exists for: the per-row delay button and "test all" hold
|
||||
// separate locks, so a user can start one inside the other. Announcing then would sort a
|
||||
// half-measured group, which is the reordering this design is meant to prevent.
|
||||
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
|
||||
|
||||
const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
|
||||
await delayManager.checkDelay(node('single') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
expect(settles).toBe(0)
|
||||
|
||||
await batch
|
||||
await nextFrame()
|
||||
expect(settles).toBe(1)
|
||||
})
|
||||
|
||||
test('unsubscribing one listener leaves the others subscribed', async () => {
|
||||
let second = 0
|
||||
const stop = delayManager.addGroupListener('g', () => {
|
||||
second += 1
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(0)
|
||||
expect(second).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
test('another group is not disturbed', async () => {
|
||||
let other = 0
|
||||
const stop = delayManager.addGroupListener('other', () => {
|
||||
other += 1
|
||||
})
|
||||
|
||||
await delayManager.checkDelay(node('a') as never, 'g', 5000)
|
||||
await nextFrame()
|
||||
|
||||
expect(settles).toBe(1)
|
||||
expect(other).toBe(0)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
+154
-34
@@ -11,6 +11,12 @@ import {
|
||||
type ResolvedProxyMember,
|
||||
} from '@/types/proxy-view'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
|
||||
|
||||
/** A group's delays, handed to sorting as a value it can depend on. */
|
||||
export type DelaySnapshot = {
|
||||
of: (member: ResolvedProxyMember) => number
|
||||
}
|
||||
|
||||
const hashKey = (name: string, group: string) => `${group ?? ''}::${name}`
|
||||
|
||||
@@ -30,7 +36,19 @@ class DelayManager {
|
||||
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
|
||||
|
||||
// 每个分组的监听
|
||||
private groupListenerMap = new Map<string, () => void>()
|
||||
private groupListenerMap = new Map<string, Set<() => void>>()
|
||||
/// A stable handle per group, replaced when that group settles. Consumers compare its
|
||||
/// identity, so it must not be rebuilt on every read.
|
||||
private groupSnapshots = new Map<string, DelaySnapshot>()
|
||||
/// Keyed by the joined group names a consumer asked for; cleared whenever any group
|
||||
/// settles, so the map identity changes while unaffected groups keep theirs.
|
||||
private groupSetSnapshots = new Map<
|
||||
string,
|
||||
ReadonlyMap<string, DelaySnapshot>
|
||||
>()
|
||||
/// Batches in flight per group. A single test that lands inside one must not announce:
|
||||
/// sorting from a half-measured group is the reordering this design exists to avoid.
|
||||
private activeBatches = new Map<string, number>()
|
||||
|
||||
private pendingItemUpdates = new Map<string, DelayUpdate[]>()
|
||||
private pendingGroupUpdates = new Set<string>()
|
||||
@@ -89,25 +107,69 @@ class DelayManager {
|
||||
this.pendingGroupUpdates = new Set()
|
||||
|
||||
groups.forEach((group) => {
|
||||
const listener = this.groupListenerMap.get(group)
|
||||
if (!listener) return
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 通知分组延迟监听器失败: ${group}`,
|
||||
error,
|
||||
)
|
||||
const listeners = this.groupListenerMap.get(group)
|
||||
if (!listeners) return
|
||||
// Copied before iterating: a listener is free to unsubscribe as it runs.
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 通知分组延迟监听器失败: ${group}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private queueGroupNotification(group: string) {
|
||||
if ((this.activeBatches.get(group) ?? 0) > 0) return
|
||||
// Dropped so the next read builds a fresh identity. Only this group's snapshot changes;
|
||||
// the set-level map is rebuilt too, but its other entries keep their identities.
|
||||
this.groupSnapshots.delete(group)
|
||||
this.groupSetSnapshots.clear()
|
||||
this.pendingGroupUpdates.add(group)
|
||||
this.scheduleGroupFlush()
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to this group's delays whose identity changes only when a test settles.
|
||||
*
|
||||
* Read during render and compared by identity, so it is cached rather than rebuilt: a
|
||||
* fresh object per read would make every consumer recompute on every render.
|
||||
*/
|
||||
/**
|
||||
* The delays for a set of groups, keyed by group name.
|
||||
*
|
||||
* Cached so its identity is stable between settles, while each group's own snapshot keeps
|
||||
* its identity unless *that* group settled — which is what lets a per-group cache survive
|
||||
* a test in a neighbouring group.
|
||||
*/
|
||||
groupsDelays(groupKey: string): ReadonlyMap<string, DelaySnapshot> {
|
||||
const cached = this.groupSetSnapshots.get(groupKey)
|
||||
if (cached) return cached
|
||||
|
||||
const names = groupKey ? groupKey.split(' ') : []
|
||||
const snapshots = new Map(
|
||||
names.map((name) => [name, this.groupDelays(name)]),
|
||||
)
|
||||
this.groupSetSnapshots.set(groupKey, snapshots)
|
||||
return snapshots
|
||||
}
|
||||
|
||||
groupDelays(group: string): DelaySnapshot {
|
||||
const existing = this.groupSnapshots.get(group)
|
||||
if (existing) return existing
|
||||
|
||||
const snapshot: DelaySnapshot = {
|
||||
of: (member) => this.getDelayFix(member, group),
|
||||
}
|
||||
this.groupSnapshots.set(group, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
setUrl(group: string, url: string) {
|
||||
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
|
||||
this.urlMap.set(group, url)
|
||||
@@ -136,12 +198,27 @@ class DelayManager {
|
||||
this.listenerMap.delete(key)
|
||||
}
|
||||
|
||||
setGroupListener(group: string, listener: () => void) {
|
||||
this.groupListenerMap.set(group, listener)
|
||||
}
|
||||
/**
|
||||
* Called when a delay test for `group` has settled: once per single test, and once for a
|
||||
* whole batch however many proxies it covered.
|
||||
*
|
||||
* "Settled" rather than "changed" on purpose. Per-proxy display updates are already live
|
||||
* through `setListener`; this exists for the things that must not move while results are
|
||||
* still arriving, chiefly sort order.
|
||||
*/
|
||||
/// Returns its own unsubscribe, so two views may watch the same group without one
|
||||
/// silently replacing the other's listener.
|
||||
addGroupListener(group: string, listener: () => void): () => void {
|
||||
const listeners = this.groupListenerMap.get(group) ?? new Set()
|
||||
listeners.add(listener)
|
||||
this.groupListenerMap.set(group, listeners)
|
||||
|
||||
removeGroupListener(group: string) {
|
||||
this.groupListenerMap.delete(group)
|
||||
return () => {
|
||||
const current = this.groupListenerMap.get(group)
|
||||
if (!current) return
|
||||
current.delete(listener)
|
||||
if (current.size === 0) this.groupListenerMap.delete(group)
|
||||
}
|
||||
}
|
||||
|
||||
setDelay(
|
||||
@@ -219,10 +296,26 @@ class DelayManager {
|
||||
return delayProxyByName(name, url, timeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one proxy, then tell the group its ordering may have changed.
|
||||
*
|
||||
* The announcement is the point at which a sorted list is allowed to re-sort. It is
|
||||
* deliberately *not* made per result inside a batch — see `checkListDelay`.
|
||||
*/
|
||||
async checkDelay(
|
||||
member: InteractableProxyMember,
|
||||
group: string,
|
||||
timeout: number,
|
||||
): Promise<DelayUpdate> {
|
||||
const update = await this.measureDelay(member, group, timeout)
|
||||
this.queueGroupNotification(group)
|
||||
return update
|
||||
}
|
||||
|
||||
private async measureDelay(
|
||||
member: InteractableProxyMember,
|
||||
group: string,
|
||||
timeout: number,
|
||||
): Promise<DelayUpdate> {
|
||||
const name = member.ref.name
|
||||
const providerName =
|
||||
@@ -287,6 +380,7 @@ class DelayManager {
|
||||
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
|
||||
)
|
||||
const names = proxies.map((member) => member.ref.name)
|
||||
this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1)
|
||||
// 设置正在延迟测试中
|
||||
names.forEach((name) => {
|
||||
this.setDelay(name, group, -2)
|
||||
@@ -294,7 +388,6 @@ class DelayManager {
|
||||
|
||||
let index = 0
|
||||
const startTime = Date.now()
|
||||
const listener = this.groupListenerMap.get(group)
|
||||
|
||||
const help = async (): Promise<void> => {
|
||||
const currMember = proxies[index++]
|
||||
@@ -313,10 +406,10 @@ class DelayManager {
|
||||
)
|
||||
}
|
||||
|
||||
await this.checkDelay(currMember, group, timeout)
|
||||
if (listener) {
|
||||
this.queueGroupNotification(group)
|
||||
}
|
||||
// Measured without announcing: a sorted list that re-ordered on every result would
|
||||
// reshuffle continuously for the length of the test, with rows moving out from under
|
||||
// the pointer. The group is told once, below, when the batch has settled.
|
||||
await this.measureDelay(currMember, group, timeout)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
|
||||
@@ -338,28 +431,55 @@ class DelayManager {
|
||||
promiseList.push(help())
|
||||
}
|
||||
|
||||
await Promise.all(promiseList)
|
||||
try {
|
||||
await Promise.all(promiseList)
|
||||
} finally {
|
||||
// In a `finally` so a throw cannot leave the group unannounced: the proxies would sit
|
||||
// at -2 and the order would stay stale with nothing later to repair it.
|
||||
const remaining = (this.activeBatches.get(group) ?? 1) - 1
|
||||
if (remaining > 0) {
|
||||
this.activeBatches.set(group, remaining)
|
||||
} else {
|
||||
this.activeBatches.delete(group)
|
||||
this.queueGroupNotification(group)
|
||||
}
|
||||
}
|
||||
const totalTime = Date.now() - startTime
|
||||
debugLog(
|
||||
`[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
formatDelay(delay: number, timeout = 10000) {
|
||||
if (delay === -1) return '-'
|
||||
if (delay === -2) return 'testing'
|
||||
if (delay === 0 || (delay >= timeout && delay <= 1e5)) return 'Timeout'
|
||||
if (delay > 1e5) return 'Error'
|
||||
return `${delay}`
|
||||
formatDelay(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
|
||||
switch (classifyDelay(delay, timeout)) {
|
||||
case 'untested':
|
||||
return '-'
|
||||
case 'testing':
|
||||
return 'testing'
|
||||
case 'timeout':
|
||||
return 'Timeout'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
case 'measured':
|
||||
return `${delay}`
|
||||
}
|
||||
}
|
||||
|
||||
formatDelayColor(delay: number, timeout = 10000) {
|
||||
if (delay < 0) return ''
|
||||
if (delay === 0 || delay >= timeout) return 'error.main'
|
||||
if (delay >= 10000) return 'error.main'
|
||||
if (delay >= 400) return 'warning.main'
|
||||
if (delay >= 250) return 'primary.main'
|
||||
return 'success.main'
|
||||
formatDelayColor(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) {
|
||||
switch (classifyDelay(delay, timeout)) {
|
||||
case 'untested':
|
||||
case 'testing':
|
||||
return ''
|
||||
case 'timeout':
|
||||
case 'error':
|
||||
return 'error.main'
|
||||
case 'measured':
|
||||
// How a measurement is graded is this widget's own decision; the thresholds differ
|
||||
// from the signal icon's on purpose, because a colour has fewer steps than four bars.
|
||||
if (delay >= 400) return 'warning.main'
|
||||
if (delay >= 250) return 'primary.main'
|
||||
return 'success.main'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
|
||||
import type { RunState } from './cmds'
|
||||
|
||||
/**
|
||||
* Every event pushed to the frontend, and what it carries.
|
||||
*
|
||||
* The names were string literals on both sides of the IPC seam, so renaming one was a silent
|
||||
* no-op rather than a compile error. Declaring them here does not make the Rust side check
|
||||
* itself, but it does mean the frontend has exactly one place to look and one payload type
|
||||
* per name instead of a `listen<T>` guess at each call site.
|
||||
*/
|
||||
interface VergeEvents {
|
||||
/** The core's own configuration changed. */
|
||||
'verge://refresh-clash-config': string
|
||||
/** The app's configuration changed. */
|
||||
'verge://refresh-verge-config': string
|
||||
/** The set of profiles changed. */
|
||||
'verge://refresh-profiles': string
|
||||
/** The active profile's proxies changed. */
|
||||
'verge://refresh-proxy-config': null
|
||||
/** A backend message for the user: `[status, message]`. */
|
||||
'verge://notice-message': [string, string]
|
||||
/** A profile's auto-update timer was rescheduled. */
|
||||
'verge://timer-updated': string
|
||||
/** How the core is running changed; carries the whole snapshot. */
|
||||
'verge://run-state-changed': RunState
|
||||
/** Which profile is active changed. */
|
||||
'profile-changed': string
|
||||
'profile-update-started': { uid?: string }
|
||||
'profile-update-completed': { uid?: string }
|
||||
/** Frontend to frontend: the home card asks the test page to run every test. */
|
||||
'verge://test-all': null
|
||||
}
|
||||
|
||||
type VergeEventName = keyof VergeEvents
|
||||
|
||||
type VergeEventHandlers = {
|
||||
[Name in VergeEventName]?: (payload: VergeEvents[Name]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to backend events, returning the teardown.
|
||||
*
|
||||
* Registration is asynchronous, so a subscription can resolve after the caller has already
|
||||
* torn down; that is tracked here rather than at each call site, where it was implemented
|
||||
* four different ways and omitted in one of them.
|
||||
*/
|
||||
export const subscribeVergeEvents = (
|
||||
handlers: VergeEventHandlers,
|
||||
): (() => void) => {
|
||||
let disposed = false
|
||||
const unlisteners: UnlistenFn[] = []
|
||||
|
||||
for (const [name, handler] of Object.entries(handlers)) {
|
||||
if (!handler) continue
|
||||
|
||||
listen(name, ({ payload }) => {
|
||||
;(handler as (payload: unknown) => void)(payload)
|
||||
})
|
||||
.then((unlisten) => {
|
||||
// Resolved after teardown: unsubscribe immediately rather than leak.
|
||||
if (disposed) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
unlisteners.push(unlisten)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[events] failed to subscribe to ${name}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
for (const unlisten of unlisteners.splice(0)) {
|
||||
try {
|
||||
unlisten()
|
||||
} catch (error) {
|
||||
console.error('[events] teardown failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay'
|
||||
|
||||
describe('classifyDelay', () => {
|
||||
test('an ordinary latency is a measurement', () => {
|
||||
expect(classifyDelay(120)).toBe('measured')
|
||||
expect(classifyDelay(DEFAULT_DELAY_TIMEOUT - 1)).toBe('measured')
|
||||
})
|
||||
|
||||
test('the core reports an in-flight test as -2', () => {
|
||||
expect(classifyDelay(-2)).toBe('testing')
|
||||
})
|
||||
|
||||
test('any other negative means we have never measured it', () => {
|
||||
expect(classifyDelay(-1)).toBe('untested')
|
||||
expect(classifyDelay(-99)).toBe('untested')
|
||||
})
|
||||
|
||||
test('zero and anything past the timeout are timeouts, not fast results', () => {
|
||||
// Zero is the trap: sorted as a number it would come first.
|
||||
expect(classifyDelay(0)).toBe('timeout')
|
||||
expect(classifyDelay(DEFAULT_DELAY_TIMEOUT)).toBe('timeout')
|
||||
})
|
||||
|
||||
test('an implausible value is an error rather than a very slow node', () => {
|
||||
expect(classifyDelay(1e5 + 1)).toBe('error')
|
||||
})
|
||||
|
||||
test('a non-number is treated as never measured', () => {
|
||||
expect(classifyDelay(Number.NaN)).toBe('untested')
|
||||
expect(classifyDelay(Number.POSITIVE_INFINITY)).toBe('untested')
|
||||
})
|
||||
|
||||
test('the timeout is configurable per test URL', () => {
|
||||
expect(classifyDelay(3000, 2000)).toBe('timeout')
|
||||
expect(classifyDelay(3000, 5000)).toBe('measured')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareByDelay', () => {
|
||||
const sorted = (delays: number[]) =>
|
||||
[...delays].sort((a, b) => compareByDelay(a, b))
|
||||
|
||||
test('faster measurements come first', () => {
|
||||
expect(sorted([300, 100, 200])).toEqual([100, 200, 300])
|
||||
})
|
||||
|
||||
test('a timeout never sorts ahead of a measurement, however it is encoded', () => {
|
||||
expect(sorted([0, 150])).toEqual([150, 0])
|
||||
expect(sorted([DEFAULT_DELAY_TIMEOUT, 150])).toEqual([
|
||||
150,
|
||||
DEFAULT_DELAY_TIMEOUT,
|
||||
])
|
||||
})
|
||||
|
||||
test('untested nodes sink below everything measurable', () => {
|
||||
expect(sorted([-1, 0, 1e5 + 1, 150])).toEqual([150, 0, 1e5 + 1, -1])
|
||||
})
|
||||
|
||||
test('a node being tested outranks one never tested', () => {
|
||||
expect(sorted([-1, -2])).toEqual([-2, -1])
|
||||
})
|
||||
|
||||
test('non-numbers sink rather than scrambling the order around them', () => {
|
||||
// This is the case the two previous copies disagreed on: one sorted a non-number
|
||||
// among the timeouts, the other after the sentinels.
|
||||
expect(compareByDelay(Number.NaN, 150)).toBeGreaterThan(0)
|
||||
expect(compareByDelay(Number.NaN, -1)).toBe(0)
|
||||
})
|
||||
|
||||
test('the order is total, so sorting is stable whichever way a pair is compared', () => {
|
||||
const direction = (value: number) => (value > 0 ? 1 : value < 0 ? -1 : 0)
|
||||
const samples = [150, 0, -1, -2, 1e5 + 1, 20]
|
||||
|
||||
for (const a of samples) {
|
||||
for (const b of samples) {
|
||||
// Summing avoids comparing against -0, which Object.is treats as distinct from 0.
|
||||
expect(
|
||||
direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)),
|
||||
).toBe(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* What a delay number means, before anyone decides how to draw it.
|
||||
*
|
||||
* The core reports latency through a single number that encodes several non-measurements:
|
||||
* sentinels for "never tested" and "testing now", zero and large values for timeouts, and
|
||||
* absurd values for errors. Every surface agreed on those rules and restated them; they are
|
||||
* stated once here.
|
||||
*
|
||||
* How a *measured* delay is graded — fast, slow, and the thresholds between — is deliberately
|
||||
* not here. A three-colour chip and a four-bar signal icon draw different distinctions, and
|
||||
* flattening them would be inventing a UI decision rather than removing a duplicate one.
|
||||
*/
|
||||
|
||||
/** The delay above which the core is considered to have timed out. */
|
||||
export const DEFAULT_DELAY_TIMEOUT = 10000
|
||||
|
||||
/** The value the core reports for a node whose test is in flight. */
|
||||
const TESTING = -2
|
||||
|
||||
/** Above this, the number is not a latency at all. */
|
||||
const IMPLAUSIBLE_DELAY = 1e5
|
||||
|
||||
export type DelayState =
|
||||
| 'testing'
|
||||
| 'untested'
|
||||
| 'error'
|
||||
| 'timeout'
|
||||
/** An actual latency measurement, in milliseconds. */
|
||||
| 'measured'
|
||||
|
||||
export const classifyDelay = (
|
||||
delay: number,
|
||||
timeout: number = DEFAULT_DELAY_TIMEOUT,
|
||||
): DelayState => {
|
||||
if (!Number.isFinite(delay)) return 'untested'
|
||||
if (delay === TESTING) return 'testing'
|
||||
if (delay < 0) return 'untested'
|
||||
if (delay > IMPLAUSIBLE_DELAY) return 'error'
|
||||
if (delay === 0 || delay >= timeout) return 'timeout'
|
||||
return 'measured'
|
||||
}
|
||||
|
||||
/**
|
||||
* The order delays sort in: measured first and fastest first, then timeouts, then errors,
|
||||
* then everything we have no measurement for.
|
||||
*
|
||||
* Ranks are compared before values so that a non-measurement can never sort ahead of a real
|
||||
* one just because its sentinel happens to be a small number.
|
||||
*/
|
||||
const rankOf = (state: DelayState): number => {
|
||||
switch (state) {
|
||||
case 'measured':
|
||||
return 0
|
||||
case 'timeout':
|
||||
return 1
|
||||
case 'error':
|
||||
return 2
|
||||
case 'testing':
|
||||
return 3
|
||||
case 'untested':
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
/** A total order over delays, for sorting a proxy list fastest-first. */
|
||||
export const compareByDelay = (
|
||||
a: number,
|
||||
b: number,
|
||||
timeout: number = DEFAULT_DELAY_TIMEOUT,
|
||||
): number => {
|
||||
const [aState, bState] = [
|
||||
classifyDelay(a, timeout),
|
||||
classifyDelay(b, timeout),
|
||||
]
|
||||
const rankDifference = rankOf(aState) - rankOf(bState)
|
||||
if (rankDifference !== 0) return rankDifference
|
||||
|
||||
// Within a rank, only a real measurement has a meaningful magnitude.
|
||||
if (aState !== 'measured') return 0
|
||||
return a - b
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
findCurrentGroupMember,
|
||||
findGroup,
|
||||
getRecord,
|
||||
isInteractableMember,
|
||||
memberDetails,
|
||||
@@ -13,10 +13,8 @@ import {
|
||||
resolveMember,
|
||||
selectGlobalChainNodes,
|
||||
selectRuleChainMembers,
|
||||
selectRuntimeStandaloneNodes,
|
||||
toMemberOccurrenceBinding,
|
||||
toNodeBinding,
|
||||
} from '../src/types/proxy-view.ts'
|
||||
} from '@/types/proxy-view'
|
||||
|
||||
const node = {
|
||||
recordId: 'p:0:0',
|
||||
@@ -70,13 +68,6 @@ test('resolves nodes only through recordId and preserves provider identity', ()
|
||||
assert.equal(isInteractableMember(resolved), true)
|
||||
})
|
||||
|
||||
test('finds group refs without constructing a name-keyed records map', () => {
|
||||
const resolved = resolveMember(view, { kind: 'group', name: 'Group' })
|
||||
assert.equal(resolved.kind, 'group')
|
||||
assert.equal(findGroup(view, 'Group'), group)
|
||||
assert.equal(memberDetails(resolved), group)
|
||||
})
|
||||
|
||||
test('keeps unresolved members visible but non-interactable', () => {
|
||||
const resolved = resolveMember(view, {
|
||||
kind: 'unresolved',
|
||||
@@ -118,7 +109,10 @@ test('rebinds a node semantically when its response-scoped id moves', () => {
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
rebindNode(movedCandidates, toNodeBinding(previous.node))?.recordId,
|
||||
rebindNode(movedCandidates, {
|
||||
name: previous.node.name,
|
||||
source: previous.node.source,
|
||||
})?.recordId,
|
||||
'p:1:0',
|
||||
)
|
||||
assert.equal(
|
||||
@@ -128,7 +122,10 @@ test('rebinds a node semantically when its response-scoped id moves', () => {
|
||||
|
||||
const duplicate = { ...movedNode, recordId: 'p:1:1' }
|
||||
assert.equal(
|
||||
rebindNode([movedNode, duplicate], toNodeBinding(previous.node)),
|
||||
rebindNode([movedNode, duplicate], {
|
||||
name: previous.node.name,
|
||||
source: previous.node.source,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
|
||||
@@ -145,36 +142,6 @@ test('rebinds a node semantically when its response-scoped id moves', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps a runtime core node that is absent from GLOBAL as standalone', () => {
|
||||
const runtimeOnly = {
|
||||
...node,
|
||||
recordId: 'c:0',
|
||||
name: 'runtime-only',
|
||||
source: { kind: 'core', proxyName: 'runtime-only' },
|
||||
}
|
||||
const excluded = {
|
||||
...runtimeOnly,
|
||||
recordId: 'c:1',
|
||||
name: 'not-in-runtime',
|
||||
source: { kind: 'core', proxyName: 'not-in-runtime' },
|
||||
}
|
||||
const standaloneView = {
|
||||
...view,
|
||||
global: { ...group, name: 'GLOBAL', members: [] },
|
||||
records: { 'c:0': runtimeOnly, 'c:1': excluded },
|
||||
standalone: ['c:0', 'c:1'],
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
selectRuntimeStandaloneNodes(standaloneView, [{ name: 'runtime-only' }]),
|
||||
[runtimeOnly],
|
||||
)
|
||||
assert.deepEqual(
|
||||
selectGlobalChainNodes(standaloneView, [{ name: 'runtime-only' }]),
|
||||
[runtimeOnly],
|
||||
)
|
||||
})
|
||||
|
||||
test('does not expose global-chain candidates without GLOBAL', () => {
|
||||
const runtimeOnly = {
|
||||
...node,
|
||||
@@ -0,0 +1,25 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Separate from vite.config.mts because that one sets `root: 'src'` for the app build,
|
||||
// while tests live alongside the code they cover *and* under tests/.
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve('./src'),
|
||||
'@root': path.resolve('.'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
OS_PLATFORM: `"${process.platform}"`,
|
||||
},
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts', 'src/**/*.test.{ts,tsx}'],
|
||||
// Node by default because most tests are pure functions; render tests opt in per file
|
||||
// with `// @vitest-environment jsdom`, so the DOM cost is paid only where it buys
|
||||
// something.
|
||||
environment: 'node',
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user