Compare commits

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

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

* fix(macos): restore legacy DNS state

---------

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

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

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

Fixes #7577

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

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

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

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

Fixes #7584

* fix: preserve manual update and profile data

---------

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

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

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

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

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

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

* chore: biome lint

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update tauri-plugin-mihomo deps

* refactor: rename ConnectionId to WsConnectionId

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

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

* fix(deps): align compact_str versions

---------

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

* fix(deps): align shared Rust dependency versions

---------

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

* fix(deps): restore frontend check compatibility

---------

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

* refresh lockfile

* fix(deps): restore TypeScript 6 compatibility

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

---------

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

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

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

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

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

Two distinctions are now explicit rather than incidental:

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

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

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

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

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

Callers now state what happened rather than what to store:

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

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

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

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

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

Tests: 218 -> 223.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rust tests 249 -> 256.

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

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

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

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

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

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

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

Rust tests 256 -> 262.

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

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

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

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

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

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

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

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

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

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

ProxySortType was declared twice; it is imported now.

Frontend tests 8 -> 21.

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

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

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

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

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

Rust tests 262 -> 267.

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

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

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

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

Frontend tests 21 -> 30.

* refactor(events): one subscription with one teardown

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

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

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

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

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

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

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

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

* fix(core): repair regressions found by review

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

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

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

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

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

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

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

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

Rust tests 267 -> 268.

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

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

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

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

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

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

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

Frontend tests 30 -> 32.

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

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

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

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

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

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

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

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

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

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

Rust tests 268 -> 289.

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

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

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

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

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

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

Frontend tests 38 -> 43.

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

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

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

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

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

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

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

Frontend tests 43 -> 45.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: Apply suggestions from code review

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

---------

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

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

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

* fix: coordinate listener port changes

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

* ci(windows): run owner security tests

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

* fix(macos): classify installed service state

* fix(macos): classify marked unavailable service

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

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

* feat(macos): prompt before migrating installed service

* fix(macos): shorten sidecar IPC path

* build(service): pin helper protocol and artifacts

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

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

* fix(ui): refresh migration state before restart

* fix(macos): linearize service migration lifecycle

* fix(ui): keep service migration state fresh

* feat(service): track authenticated owner sessions

* fix(service): preserve newer owner sessions

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

* fix(macos): serialize service proxy guard

* fix(macos): close service proxy owner lifecycle

* fix(macos): close remaining proxy stop races

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

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

* fix(macos): stage dev core for service

* test(macos): exercise staged core cleanup

* fix(dev): await controlled macOS exit

* chore: update changelog

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

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

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

* fix(macos): localize launch guard prompts

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

* feat: support seamless service migration

* fix: pass Windows service installer path safely

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

* fix(dev): preserve service installation state

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

* feat(proxy): expose ordered proxy view command

* feat(proxy): add proxy view client contract

* fix(lint): allow Node test files

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

* fix(proxy): harden ordered proxy view consumers

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

* fix(proxy): release mihomo guard after fetch

* fix(proxy): preserve semantic selections across refresh

* fix(proxy): block global chain without GLOBAL

* fix(proxy): handle proxy view edge states
2026-07-21 00:11:59 +08:00
wonfen fd70d4c62b fix(enhance): make GUI TUN config authoritative 2026-07-20 12:33:47 +08:00
Tunglies d7dbff9f08 chore(hooks): block pushes on knip errors 2026-07-20 02:52:04 +08:00
Tunglies c85928ecaf fix(i18n): keep language section loader private 2026-07-20 02:50:49 +08:00
Tunglies e63b8e6eb0 perf(frontend): eliminate navigation loading states 2026-07-20 02:43:38 +08:00
Tunglies 971dd37965 chore: bump version 2.5.3 2026-07-20 00:24:35 +08:00
❤是纱雾酱哟~andTunglies 28f2efc504 refactor(scripts): optimizes file permission changes (#7520)
- Replaces shell-based execution of `chmod` with native Node.js filesystem API calls to improve performance
- Switches to `execFileSync` in stream event handlers to avoid shell spawning overhead

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2026-07-19 15:23:31 +00:00
Tunglies 12aa989af1 fix(knip): remove unused proxy order export 2026-07-19 21:29:30 +08:00
❤是纱雾酱哟~ 53a0971826 chore(ci): Explicitly limit token permissions (#7519)
- Explicitly set permission boundaries for `GITHUB_TOKEN`
- Prevent overly permissive default tokens

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-07-19 10:15:27 +00:00
wonfen 50d828d0d7 fix(profile): handle missing chain files without breaking rollback 2026-07-19 08:44:24 +08:00
cjcandwonfen 6e9288437f Related to #6759 (windows): ensure sidecar process dies with the main app (#6853)
* fix(windows): ensure sidecar process dies with the main app

* chore: fix clippy lints

* chore: fix TS2307 error on commit.

chore: Undo the changes about package.json and pnpm-lock.yaml

* Fix formatting errors caused by merging.

刚刚的合并让原本的函数少了个结尾括号

* fix(windows): close process handle after assigning sidecar to job object

* fix(windows): sidecar Job Object 错误改为向上传播并补充测试

将 job 创建逻辑抽为返回 Result<OwnedHandle> 的独立函数,任一 Win32 调用失败时
终止已 spawn 的子进程并返回 Err,不再静默降级。句柄由裸 isize 改为 OwnedHandle,
借助 RAII 自动关闭。新增 Windows 单元测试,覆盖 KILL_ON_JOB_CLOSE 生命周期
及无效 PID 的失败路径。

---------

Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
2026-07-19 07:30:38 +08:00
cxymdsandwonfen 6bbe8bd37d fix: isolate macOS service logs from app logs (#7236)
* fix: isolate macOS service logs from app logs

* fix: migrate legacy macos logs

---------

Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
2026-07-19 06:49:37 +08:00
wonfen 4b04d035b6 fix(profile): schedule imported profiles for auto-update 2026-07-19 06:39:48 +08:00
oomeow becd86c179 feat(home): hide hidden proxy groups in current proxy card (#7504)
* feat(home): hide hidden proxy groups in current proxy card

* docs: update changelog with fixes and feature clarifications
2026-07-18 15:16:37 +00:00
wonfen b31437f727 fix(ci): pin release target commit 2026-07-18 15:33:43 +08:00
wonfen b7978705af fix: support VLESS xhttp and normalize IPv6 addresses 2026-07-17 23:51:29 +08:00
timelicandwonfen 950e8195fb style: refine navigation item styling (#6578)
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-07-17 04:20:02 +00:00
wonfen bdeaf2a062 perf: improve drag performance for large profile lists 2026-07-17 03:08:22 +08:00
oomeow f6440175d8 fix(signal): use default runtime (#7495) 2026-07-16 12:19:35 +00:00
oomeow 96e9564295 style(home): fix layout shift in current-proxy-card by adding fixed heights (#7494) 2026-07-16 12:19:23 +00:00
Tunglies d327a435ff fix(profile): validate profile file names 2026-07-16 16:49:11 +08:00
wonfen f09aef170d fix(proxy): preserve runtime proxy group order 2026-07-16 06:53:21 +08:00
wonfen d144e78aeb perf: streamline profile switching 2026-07-16 05:52:10 +08:00
wonfen 6ea7f544b2 fix: dispatch frontend events on main thread 2026-07-16 04:27:47 +08:00
wonfen cd20bdb6a1 fix: safely restore selected proxy nodes 2026-07-16 04:16:42 +08:00
wonfen 6c149d6234 chore: update service-ipc version 2026-07-16 02:42:54 +08:00
wonfen 993cc2ffb1 fix(core): skip service wait for elevated TUN startup 2026-07-15 04:30:55 +08:00
ysakura99andwonfen f1e0622813 fix(connections): show destination IP as host for raw-IP targets (#7483)
* fix(connections): show destination IP as host for raw-IP targets

When metadata.host is empty (a raw-IP CONNECT with no domain), the
connection "Host" column fell back to remoteDestination (the outbounds
relay/exit address) joined with the destination port, which misleadingly
showed the relay as the connections destination.

Fall back to metadata.destinationIP instead, in both the list column
(getConnectionHost) and the detail panel.

Closes #6669

* fix(connections): preserve fallback for empty destination IP

---------

Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-07-14 14:42:07 +08:00
Tunglies 7ca20fc4ed refactor: knip cleanup (#7468)
* chore(frontend): expand Knip reports

* chore: remove unused Mihomo changelog sync script

* refactor: remove unused value exports

* refactor: remove unused type exports

* refactor: remove circular route dependencies

* chore(frontend): move Knip scope to config

* chore(i18n): remove stale portable update message
2026-07-13 11:29:25 +08:00
wonfen 6f9fb97b31 fix: clippy 2026-07-13 11:09:13 +08:00
wonfen eaf4fc2c17 fix: handle empty HTTP request timeout in profile editor 2026-07-13 01:31:44 +08:00
wonfen 7155047e02 fix(backup): accept numeric UTC WebDAV dates 2026-07-13 01:21:26 +08:00
oomeow 0337340471 fix(proxy): increase debounce delay and refine head state logic (#7464) 2026-07-12 08:22:22 +00:00
Tunglies 14bc636ee8 refactor(frontend): remove unused modules 2026-07-12 12:42:27 +08:00
Tunglies 656b6b6d44 chore(frontend): add Knip unused file report 2026-07-12 12:42:27 +08:00
wonfen 604fdb9252 chore: update changelog 2026-07-12 04:20:14 +08:00
doudouabcandwonfen 6ad480f898 修复局域网无法使用的BUG (#7428)
Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
2026-07-12 02:05:55 +08:00
Tunglies 0c86b70fe9 refactor(frontend): reuse sortable arrayMove 2026-07-11 18:58:31 +08:00
Tunglies 6d3761a9ee refactor(frontend): remove obsolete monitor diagnostics 2026-07-11 17:48:16 +08:00
Tunglies ab1e15dceb refactor(frontend): remove retired test page 2026-07-11 16:55:56 +08:00
Tunglies 34fee12496 fix(frontend): warm lazy routes concurrently after idle
Start all navigation route preloads after a single idle gate and show centered loading fallbacks during cold route loads.
2026-07-11 18:01:53 +08:00
oomeow 1847002ebe fix: group tools filter and layout (#7431)
* fix(proxy): group filter

* refactor(proxy): improve layout and structure of proxy render components

* refactor(proxy): remove unnecessary flushSync calls in proxy-group-tools

* feat(proxy): debounce filter input in ProxyGroupTools

* refactor(base-search-box): initialize lastSearchStateRef with current state

* refactor(proxy): remove debug console.log in proxy-groups

* refactor(proxy): improve scrolling behavior in proxy groups

* refactor(proxy): improve scroll position restoration logic

* chore(proxy): reset proxy group tools layout

* style(proxy): adjust margins and spacing in proxy components

* refactor(proxy): remove commented out ProxyGroupTools component
2026-07-11 07:38:32 +00:00
oomeowandTunglies 562828e14d fix: provider proxy delay check (#7456)
* refactor(proxy): support delay testing for provider-based proxies

* style: remove debug console logs

* docs: update changelog

* feat(services): merge provider nodes into records in calcuProxies

---------

Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2026-07-11 05:24:41 +00:00
Tunglies e8cefa8b07 perf(frontend): vectorize home navigation icon 2026-07-10 20:05:41 +08:00
Tunglies b53227dab5 perf(frontend): optimize initial dependency loading 2026-07-10 19:43:55 +08:00
wonfen 2557e9a222 fix(ci): preserve release metadata during builds 2026-07-10 03:32:34 +08:00
wonfen 7085d2bea7 fix(scripts): harden publish-version tag creation 2026-07-09 14:24:08 +08:00
Ming 7e3544cbcc fix(macos): repair menu bar window activation order (#7082) 2026-07-09 05:59:11 +00:00
wonfen 979defc070 fix: prevent rules editor from clearing custom rules before load 2026-07-09 13:46:24 +08:00
StarsSail 406c0ab5a7 fix: parse error (#7430) 2026-07-08 03:35:03 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
8bf5fc1aca chore(deps): update cargo dependencies (#7377)
* chore(deps): update cargo dependencies

* fix aes feature

* fix: update aes-gcm nonce conversion

* refactor: use aes-gcm key init api

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-07-06 06:14:15 +00:00
StarsSail d4118295a3 fix: remove unnecessary input fields (#7423)
* fix: remove unnecessary input fields

* fix: remove unnecessary input fields from ProfileViewer component
2026-07-06 02:43:18 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d7fca9fd70 chore(deps): update dependency @tauri-apps/cli to v2.11.4 (#7376)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 02:35:28 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> bd90d159ee chore(deps): update github actions (#7383)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 02:33:58 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> be15131080 chore(deps): update tauri-apps/tauri-action action to v1 (#7384)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 02:33:39 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> c8af762d8b chore(deps): lock file maintenance (#7390)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 10:30:14 +08:00
Tunglies afbc14840c fix: warm lazy routes after startup visibility 2026-07-06 09:13:11 +08:00
Tunglies 1d7462a021 fix: stop idle mihomo websocket subscriptions 2026-07-06 08:33:23 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 20ac214a83 chore(deps): lock file maintenance (#7389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 00:20:55 +00:00
StarsSail 0407384557 fix: rust lint error (#7424) 2026-07-06 00:19:58 +00:00
wonfen b3f07acb5b fix: limit mihomo yaml wildcard quoting to fake-ip-filter 2026-07-06 06:49:43 +08:00
StarsSail 3da6d7e466 fix: quote wildcard array items in global extension script output (#7414) 2026-07-04 18:20:37 +00:00
wonfen 49ea0cd5df fix(config): remove redundant write that clobbered global hotkey setting 2026-07-05 01:10:56 +08:00
wonfen ac156afc99 fix: macos CI 2026-07-04 23:25:19 +08:00
sgt57 c4658cff88 feat: add window resize handles on linux (#7319) 2026-07-04 03:16:08 +00:00
wonfen 4fa2f9a329 fix(macos): recover webview and stop ws leak after render process terminate 2026-07-04 09:39:44 +08:00
GrainFull 1f76c67afb fix: recover webview lazily and stop ws leak after render process ter… (#7410)
* fix: recover webview lazily and stop ws leak after render process termination

macOS 内存压力下 WKWebView 渲染进程可能被系统杀掉,导致:
1. 从托盘打开主窗口时白屏;
2. 前端无法清理 Mihomo WebSocket 订阅,孤儿订阅持续将 >1KB 的
   payload 写入 tauri ChannelDataIpcQueue 且无人取走,主进程内存
   以 ~150MB/h 持续增长。

注册 on_web_content_process_terminate 钩子:
- 立即清理全部 Mihomo WebSocket 订阅阻断泄漏(托盘速率任务会自动重连);
- 窗口可见时立即 reload 拉起渲染进程恢复页面;窗口隐藏/最小化时仅置位
  待重载标记,等用户下次打开窗口时再 reload——系统正是因内存压力才杀掉
  不可见窗口的渲染进程,立即重建既浪费内存也可能形成反复被杀的循环。

另修复 tray_event 配置为 tray_menu 时误报 invalid tray event 警告。

* fix: gate logging import to macos to satisfy clippy on other platforms
2026-07-04 00:21:03 +00:00
wonfen 336bd9d735 fix(enhance): protect app-owned config from manual overrides 2026-07-04 07:45:13 +08:00
StarsSail e578e473d5 fix: prevent input focus loss in allowOrigins (#7397)
* fix: update CORS configuration to use key-value pairs for allowed origins

* fix: refactor CORS configuration to use AllowOriginItem interface for better type safety
2026-07-03 17:53:51 +00:00
oomeow dd3ef698f5 feat: sticky groups (#7195)
* feat(proxy): implement sticky virtual list and group tools

- Add new StickyVirtualList component for better performance with sticky headers
- Create ProxyGroupTools component for filtering, sorting and testing proxy groups
- Enhance proxy groups with improved scrolling and group toggle functionality
- Integrate search box with click event handling
- Remove redundant ProxyHead component usage

* style: remove unused import and update TypeScript types

* refactor: convert function component to forwardRef component to fix biome lint error

* feat(proxy): extract proxy chain UI to separate component

* refactor: remove custom throttle implementation and simplify dependencies

* feat(proxy): improve proxy groups UI and scrolling behavior

- Add initialOffset prop to StickyVirtualList for better scroll position restoration
- Refactor ProxyGroups component to separate chain and normal modes
- Implement scroll position persistence for both chain and normal modes
- Add sticky shadow effect for expanded proxy groups
- Remove unnecessary ScrollTopButton component
- Optimize scroll event handling with throttling
- Improve code organization with custom hooks and component separation

* docs(proxy-groups): Add TODO comment for initial render position issue

* feat(proxy): improve scroll position handling and UI display

* refactor(proxy): simplify scroll handling logic and remove unused code

* fix(sticky-virtual-list): remove duplicate styles and add padding

* refactor(proxy): rename scroll offset variables and functions for clarity

* refactor: remove unnecessary refs and simplify onClick handlers

* fix(proxy-groups): handle initial scroll position state

* feat(zed): add biome formatter configuration

* refactor(proxy): remove unused headState import and type annotations

* style(proxy): improve type imports and conditional rendering

* .

* feat(proxy): add ProxyHead component to render in global mode

* chore: ignore .zed directory

* docs: update changelog
2026-07-02 13:22:40 +00:00
oomeow c2ddb5c111 refactor: activate selected proxies (#7285)
* feat(profiles): add automatic node activation after profile switch

* refactor(use-profiles): simplify patchCurrent logic by always mutating profiles

* refactor(use-profiles): remove unused activateSelected function and related imports

* refactor: rename worker handle to activate selected task

* fix: clippy

* refactor(config): replace log statements with structured logging

* refactor(config): fix incorrect retry logic in profile activation

* feat(profiles): update proxy cache when switching profiles

* docs: update changelog with bug fixes and logic improvements

* refactor(profiles): remove unused imports and redundant cache update
2026-07-01 12:47:03 +00:00
wonfen af0518d578 fix: guard app setup panics during startup 2026-07-01 11:51:22 +08:00
Tunglies 9a2f4ff8fb fix: warm lazy navigation routes after startup 2026-06-30 21:34:32 +08:00
oomeow e176e4a81e fix(validate): mock console object in validate step (#7358)
* feat(core): mock console object in boa engine context to suppress logs

* style(core): update quote style in validate.rs
2026-06-30 11:27:28 +00:00
oomeow 728c7535c4 feat: supports in-app online update for Linux (#7259)
* feat(updater): add support for multiple Linux package formats and Windows NSIS installers

* style(updater): use template literal for URL concatenation

* docs: add platform format comments to updater.mjs

* test: downgrade version to 2.5.0 to test updater

* chore: add Zed editor settings

* Revert "test: downgrade version to 2.5.0 to test updater"

This reverts commit 9dd6bed5e9.

* chore: format

* chore: remove zed editor settings
2026-06-30 11:26:58 +00:00
Tunglies ad68e445dd rm unused workflow 2026-06-30 18:59:09 +08:00
Tunglies a862667d9b fix: defer setup resources until after first window 2026-06-30 12:53:21 +08:00
Tunglies 4aec41d3be fix: defer tray proxy menu refresh 2026-06-29 18:52:11 +08:00
Tunglies 5fd2175b06 fix: read enhance chain items concurrently 2026-06-29 18:28:18 +08:00
Tunglies fa712b59c9 fix: reuse preloaded verge config 2026-06-29 16:55:25 +08:00
wonfen deae6442a7 fix(backup): make WebDAV directory creation idempotent 2026-06-29 12:09:55 +08:00
Tunglies 520746428c fix: dedupe initial websocket subscriptions 2026-06-29 11:54:05 +08:00
wonfen 3b52fceca1 fix: DNS overrides could not be edited in advanced mode 2026-06-29 11:31:57 +08:00
Tunglies 3ce228ff7a fix: lazy load i18n sections 2026-06-29 11:11:57 +08:00
Tunglies 2442634b2a fix: lazy load non-home routes 2026-06-29 06:30:54 +08:00
Tunglies 4ec3a9bf4c fix: defer provider queries on startup 2026-06-29 06:03:51 +08:00
Tunglies 2a5d29cdb2 fix: defer runtime config init until after window 2026-06-29 05:19:38 +08:00
Tunglies 3a4cd2c361 Revert "fix: update js-yaml imports for v5"
This reverts commit 250198d547.

Revert "chore(deps): update dependency js-yaml to v5 (#7331)"

This reverts commit b44f671fc2.
2026-06-29 05:17:04 +08:00
Tunglies 250198d547 fix: update js-yaml imports for v5 2026-06-29 03:28:46 +08:00
Tunglies 55060d422a fix: include url-test groups in proxy selectors 2026-06-29 03:14:03 +08:00
Tunglies dad5121b7d fix(service): harden privileged service path execution 2026-06-28 21:21:01 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> dad18ce90f chore(deps): update dependency @tauri-apps/cli to v2.11.3 (#7324)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 11:52:34 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ac8a9ca7d8 chore(deps): update actions/cache action to v6 (#7342)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 11:52:29 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 1d3a7107d9 chore(deps): update cargo dependencies (#7332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 11:52:25 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 5ef85bb269 chore(deps): update github actions (#7326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 11:52:17 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b44f671fc2 chore(deps): update dependency js-yaml to v5 (#7331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 11:51:50 +00:00
Tunglies b9da0875cd refactor: optimize swr data refresh paths 2026-06-28 19:42:21 +08:00
Tunglies e71e701fd0 refactor: migrate query cache to swr 2026-06-28 19:39:15 +08:00
wonfen 294955cb8c feat: add fake-ip-range6 2026-06-28 13:08:55 +08:00
StarsSail 16e15ed9d4 fix: improve hide_main_window logic when hotkey pressed (#7322) 2026-06-27 23:50:36 +00:00
wonfen 86072e599d chore: update change log 2026-06-28 07:48:47 +08:00
wonfen b7157fac15 fix(core): serialize core lifecycle transitions
Add a lifecycle lock around core start, stop, restart, and service handoff
paths. Make start_core idempotent, skip startup while exiting, and roll
back running mode after startup failures so later retries are not blocked.
2026-06-28 07:23:57 +08:00
wonfen 7cad713cac chore: update clash-verge-service-ipc version to 2.3.1 2026-06-28 04:34:44 +08:00
wonfen 1112f0730d fix(core): improve service startup and cleanup reliability
Wait longer for slow Windows service startup, then hand off from sidecar
to service when it becomes ready. Retry service core startup during the
handoff window, and clean up root-owned macOS service core/IPC leftovers
before uninstalling the service.
2026-06-28 04:34:01 +08:00
wonfen 1e44bafa79 fix(service): notify users about macOS App Translocation
Show a delayed localized error notice when Clash Verge is running from a
translocated temporary path and no stable core path can be resolved.
2026-06-28 03:48:13 +08:00
Yao Jingxiandwonfen 4c46223107 fix: handle macOS service AppTranslocation state (#7340)
Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
2026-06-28 02:24:09 +08:00
wonfen b7a454fedb chore: track tauri-plugin-mihomo fix/api-robustness branch
Pulls in the plugin's response-deserialization tolerance, connection-pool permit fix, single reqwest::Client reuse, and WebSocket secret encoding/redaction (cf97ff9).
2026-06-27 15:39:00 +08:00
wonfen 5a96fdd9a2 fix: harden home clash-mode card against /configs read failures
Recover from controller read/switch failures on the mode card instead of
showing a bare "communication error":

- fall back to the tolerant get_clash_mode command (then runtime config)
  when the live /configs mode is unavailable,
- optimistically reflect the clicked mode and write it through to the
  getClashConfig cache so a failed refetch can't flash back the old value,
- surface patch failures via showNotice on both the home card and the
  proxies page (patchClashMode can now reject), and
- revalidate getClashMode/getRuntimeConfig on refresh-clash-config so a
  once-empty fallback can recover.
2026-06-27 15:30:20 +08:00
wonfen d0d2f4e4f5 fix: read clash mode tolerantly and propagate mode-switch failures
The home mode card read the proxy mode only from mihomo's /configs (strict
BaseConfig); when that response drifted a single field, the whole read
failed and the card showed a bare "communication error". patch_clash_mode
also swallowed backend PATCH failures and always returned Ok, so the UI
could not tell a switch had actually failed.

- add a tolerant get_clash_mode command that reads mode from the saved
  clash config (IClashTemp::get_mode), bypassing strict BaseConfig
  deserialization, and
- make change_clash_mode return Result and have patch_clash_mode propagate
  it; the hotkey/tray callers log-and-ignore as before.
2026-06-27 15:30:20 +08:00
oomeow 1026ba88bb chore: update biome config and ignore zed settings dir (#7364)
* chore: update biome config and ignore zed settings dir

* chore: remove trailing slash from .vfox directory in .gitignore
2026-06-26 12:46:45 +00:00
Bruce Gorman a00df6c93d fix: refresh rules data when clash config is reloaded (#7299) 2026-06-22 11:12:36 +00:00
oomeow 9b87cd53e1 fix(tray): tray icon flickering on macOS (#7335)
* fix(tray): tray icon flickering on macOS

* refactor(tray): use explicit cfg blocks for template logic
2026-06-21 19:28:39 +00:00
wonfen b4a490fbeb doc: add Sponsor 2026-06-21 00:28:43 +08:00
Tunglies de438aadf8 fix(ci): skip bot PRs in AI slop review 2026-06-20 00:23:48 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 688dc0b9e1 chore(deps): update github actions to v7 (#7318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 06:53:15 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 651258479b chore(deps): update github actions (#7317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 06:53:05 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 468483660a chore(deps): update npm dependencies to v8 (#7310)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 06:52:50 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 138901f26b chore(deps): update npm dependencies to v2.11.1 (#7309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 06:52:37 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 9867a2af99 chore(deps): update cargo dependencies (#7307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 06:52:34 +00:00
wonfen a44173e3c7 chore: add Friendly reminder 2026-06-19 00:14:11 +08:00
Tunglies 4ee63b6cf2 perf(proxies): optimize virtual list scrolling 2026-06-18 20:53:22 +08:00
Tunglies f7ed126bb8 fix: update mihomo plugin config enum handling 2026-06-18 20:05:31 +08:00
Tunglies fd8c852698 fix: update mihomo plugin log handling 2026-06-18 19:18:10 +08:00
sijie-Z 42c9e19465 fix: crash on malformed ss:// URI with invalid v2ray-plugin data (#7253)
* fix: wrap v2ray-plugin JSON.parse in try-catch to prevent crash on malformed URIs

A malformed ss:// URI with invalid base64-encoded v2ray-plugin query
parameter causes an unhandled JSON.parse exception that crashes the
profile import flow. This matches the error handling pattern already
used in vless.ts and vmess.ts.

* fix: format JSON.parse and sort callback to satisfy biome
2026-06-18 10:33:35 +00:00
StarsSail e5c1c852d7 fix(logs): simplify scroll reference by removing length tracking (#7270) 2026-06-18 10:31:38 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a528b534b4 chore(deps): update github actions to v0.80.4 (#7301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 08:56:12 +00:00
wonfen 7e233efd10 feat: add reset button to Global script 2026-06-18 15:13:38 +08:00
57b891215e fix: restore default size for collapsed window state (#7215)
Co-authored-by: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com>
Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
2026-06-18 13:23:02 +08:00
sijie-Z 4105dc7479 fix: improve type safety and remove redundant type casts (#7252)
* fix: improve type safety and remove redundant type casts

- Fix @@ts-expect-error typo (double @) in traffic-diagnostics.ts
- Replace (e.target as any) with (e.target as HTMLElement) in setting-clash.tsx
- Remove redundant (onFormat as any) cast in guard-state.tsx

* fix: format api.ts sort callback to satisfy biome

* fix: remove as any, let @ts-expect-error suppress the non-standard API error

* fix: remove invalid @@ts-expect-error comment, keep as any for non-standard API
2026-06-17 12:47:31 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> aa0d5f6004 chore(deps): update github actions (#7153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 04:40:07 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 67cc2a63af chore(deps): lock file maintenance npm dependencies (#7171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 04:40:00 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 276afb2429 chore(deps): update npm dependencies (#7179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 04:39:52 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 894c73d602 chore(deps): update cargo dependencies to 1.12.4 (#7260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 04:39:34 +00:00
Tunglies 220474888c fix: clippy error 2026-06-16 12:39:14 +08:00
Tunglies 658312fe7b fix: keep home current proxy in sync 2026-06-13 21:20:52 +08:00
Tunglies 55ce7bffe4 refactor: avoid embed server clone 2026-06-13 19:51:14 +08:00
Tunglies 2ddd95cf40 refactor(macos): avoid speed text clone 2026-06-13 19:48:49 +08:00
Tunglies f763fcb244 fix: fallback clash mode from runtime config 2026-06-13 14:47:29 +08:00
Tunglies 1e57f41190 fix: use mihomo traffic totals 2026-06-13 14:03:44 +08:00
Tunglies 27161876f0 refactor: avoid allocating default tray icons 2026-06-13 13:37:15 +08:00
Tunglies e1380b9bab fix: frontend build after bump version for tauri-plugin-mihomo 2026-06-10 20:01:19 +08:00
Tunglies 358dff9c62 chore: fix pre-push script 2026-06-10 16:38:39 +08:00
Tunglies 35c534ae1c refactor: lazylock for profile regex 2026-06-10 16:38:39 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b7f8ca0a83 chore(deps): update cargo dependencies to 2.13.0 (#7225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 13:25:21 +00:00
Tunglies 3c3d3f2443 fix: bump tauri-plugin-mihomo to 0.5.2 for better forward compatibility 2026-06-09 20:22:37 +08:00
Tunglies 6a0cc21244 lint: add eslint checks for frontend compatibility 2026-06-09 14:12:13 +08:00
oomeowandTunglies ee7f66f49c fix(macOS): early setup macOS activation policy (#7246)
* fix(macOS): early setup of macOS activation policy

* refactor

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-06-09 05:32:47 +00:00
sijie-ZandTunglies b79e3a0569 fix: replace toSorted with slice().sort() for macOS 12 compatibility (#7247)
* fix: replace toSorted with slice().sort() for macOS 12 compatibility

Array.prototype.toSorted() is ES2023 and not supported in WebKit < 16
(bundled with macOS 12 Monterey). Using slice().sort() is functionally
identical and works on all platforms.

Fixes #7216

* chore: update Changelog

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-06-09 05:11:58 +00:00
wonfen 2828782e4c chore: update sysproxy-rs 2026-06-07 23:44:17 +08:00
Tunglies ca325f1cb8 feat: remove global-client-fingerprint
Ref: https://github.com/MetaCubeX/mihomo/commit/bd72c65b5e93c0790abb09b95d519aaf644c4b81
2026-06-07 14:36:18 +08:00
Tunglies 45afb1c15a fix(frontend): reduce long-running memory pressure in connections (#7227)
* fix(connections): stabilize table memory usage

Render the connections table and list from stable row view models, and replace the TanStack row/cell instantiation path with a lightweight virtualized table body.

The leak was isolated to the frontend table render path: websocket data without the table stayed flat, while the normal table path kept producing large per-snapshot object graphs under active/closed churn.

Introduced-by: bf06cbc87d (introduced the TanStack table path)

Related-hot-path: 068678135 (made default ordering/filtering hot enough for long-running /connections sessions to amplify retained heap/RSS)

Related-retention-path: 7fc238c27b (moved Home stats to the full connection websocket subscription, so Home could keep connection snapshots alive for totals/count)

Keep UI behavior intact: column visibility, column order, resizing, sorting, connection details, the closed-connection cap, and the 500ms refresh cadence remain.

* fix(connections): lazily release connection snapshots

Keep the full active/closed connection snapshot scoped to /connections by using a short React Query gcTime for the full subscription.

Home now subscribes to a lightweight connection summary for totals and active count, so leaving /connections no longer keeps the full active/closed arrays and closed history alive for the dashboard.

Only the selected Active or Closed mode feeds filtering, sorting, row-view derivation, and table/list rendering. Switching modes remounts the row subtree and closes detail/column-manager state without changing the capped closed-history behavior.

* fix(connections): decouple collection from rendering

Move the high-frequency connection websocket path out of React Query and into a module-level external store consumed with useSyncExternalStore.

The store keeps one normalized active snapshot plus the capped closed history, continues collecting across route changes, and only notifies mounted subscribers. Home reads the same store through a lightweight summary snapshot, so leaving /connections no longer renders the table/list while connection history is still preserved.

This replaces the short-gc release behavior that made Active/Closed history disappear after navigating away, and bounds retained data to current active connections plus the closed cap instead of React Query cache objects.

* fix(connections): keep collection store authoritative

Remove the temporary per-subscription gcTime override added for /connections. The connection monitor now owns active/closed retention outside React Query, so route changes should stop rendering without clearing collected history.

This leaves the shared Mihomo websocket hook on its original 30s cache behavior for logs, traffic, and memory while /connections uses the external store introduced in the previous commit.

* perf(connections): coalesce snapshots before parsing

Keep only the latest raw websocket message during the 500ms throttle window and parse it when the store flushes. High-churn connection bursts no longer allocate parsed object graphs for snapshots that will be overwritten before React can observe them.

Also compare incoming metadata through the same normalization used for retained records, so optional undefined fields do not prevent metadata reference reuse.

* perf(connections): trim render allocations

Reuse existing row view objects before formatting derived strings when the retained connection object reference is unchanged. Stable closed-history rows no longer allocate throwaway display strings on every snapshot.

Measure the table scroll viewport on mount and resize instead of assuming a 1200px body, so virtual rendering starts from the real visible row count. Keep table state across Active/Closed switches and share the relative-time cell between table and list rows.

* perf(connections): tighten shared time updates

Refresh the shared relative-time clock before the first subscriber render and switch the listener registry back to a Set, avoiding stale labels after returning to /connections and keeping subscriber churn O(1).

Also sort table time rows by the existing numeric startTime field, drop the redundant row-view startTime equality check, and remove one unnecessary table prop guard.

* perf(connections): skip table-wide row projection

Stop materializing ConnectionRowView objects for the whole filtered dataset in table layout. The page now keeps raw connection records for the table path and only formats visible cells inside the virtualized table body, while list layout keeps the cached row-view path.

This removes the steady 500ms allocation pass that was still walking every filtered connection before table rendering. Detail lookup now reads the current filtered raw record directly, and the table clamps scroll state when switching between datasets of different lengths.

* perf(connections): skip unnecessary filter clone when table not searching

In table mode, avoid creating a filtered copy when search is empty: only apply search/sort work for non-empty query while retaining the same default order behavior. Also replace  parsing in order comparators with numeric startTime to avoid per-tick Date object churn and use SearchBox state to detect an empty search.

In table layout without search input, use upstream connection snapshot directly for rendering instead of cloning/sorting every interval tick. This removes a persistent per-update array allocation path while keeping list-mode sort/search behavior unchanged.

* perf(connections): bound closed merge without full transient arrays

Cap closed-history promotion while iterating previous active set without building a temporary large closed array. This preserves the 500-item limit while avoiding 100k-scale transient allocations when many connections close between snapshots.

* fix(changelog): address memory leak caused by frontend

* perf(connections): cache visible table cell snapshots

Add a bounded row snapshot cache for the virtualized table so repeated paints and optional column sorting reuse stable formatted strings instead of rebuilding host/source/rule/chains/traffic text on every refresh. The cache is capped to avoid retaining large active sets.

Also skip full connection-data snapshot merging when only summary listeners are mounted, keeping Home on the summary-only path without changing Connections history while the page is subscribed.

* chore: update Changelog

* chore: update Changelog
2026-06-07 13:08:15 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ee9d5126d3 chore(deps): update cargo dependencies (#7213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 14:02:27 +00:00
Tunglies 66d104593b fix(logs): avoid forced scroll-to-bottom on refresh
Closes #7188
2026-06-04 12:33:27 +08:00
Tunglies bc2149c66d fix(logs): stop always keeping logs route mounted 2026-06-03 21:56:33 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 462d9f536d chore(deps): update cargo dependencies (#7141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 13:46:05 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> b5b41956e2 chore(deps): update dependency commander to v15 (#7156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 13:22:26 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f1df8afb76 chore(deps): lock file maintenance (#7170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 13:22:09 +00:00
Tunglies 595f28d428 ci: harden AI slop review heuristics 2026-06-02 21:19:30 +08:00
Tunglies fa27d3eae5 Revert "feat(cvd): add device-bound subscription protocol"
This reverts commit 2cb9c13ab6.
2026-06-02 21:02:19 +08:00
wonfen 2cb9c13ab6 feat(cvd): add device-bound subscription protocol 2026-06-02 13:43:54 +08:00
Tunglies 9c639bfcc5 fix: run exit cleanup before app shutdown
Close #7158
2026-05-30 16:01:25 +08:00
Tunglies 232be33754 fix(draft): serialize async updates 2026-05-30 13:31:10 +08:00
Tunglies 3444d594ac perf(profile): optimize profile batch update and worker config 2026-05-29 20:18:40 +08:00
Tunglies 6e6c537e6b refactor service manager locking 2026-05-28 23:22:56 +08:00
Tunglies 08fd2ce68b fix(core): serialize runtime config updates 2026-05-28 17:41:39 +08:00
wonfen 43843f1eca chore: change texts 2026-05-28 13:04:33 +08:00
Tunglies dc094033b8 Revert "refactor: remove subscription TLS roots fallback"
This reverts commit 746279fa03.
2026-05-28 02:42:13 +08:00
Li Yangandliyang f916711717 fix(tray): use an app-specific tray id (#6772)
Co-authored-by: liyang <liyang25@pku.edu.cn>
2026-05-26 13:34:39 +00:00
Tunglies 761209bbbc refactor: backend (#7102)
* refactor(tray): move tray log opening to utils helper

* refactor(enhance): move owned config through pipeline

* refactor(enhance): move sequence values in use_seq

* refactor(enhance): move values while sorting fields

* refactor(enhance): move values when lowercasing owned config

* refactor(backend): move owned config values in utilities

* refactor(tray): borrow hotkey accelerators

* refactor(core): borrow proxy config strings

* fix(backend): preserve config patch semantics

* refactor(enhance): clarify config transform names
2026-05-26 10:47:03 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a287d13b4d chore(deps): update github/gh-aw-actions action to v0.76.1 (#7098)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 08:47:35 +00:00
Siyuan LiangandTunglies 22c516f550 feat(sysinfo): return structured system info (#7122)
* feat(sysinfo): return structured system info

* refactor: simplify SystemInfo conversion and enhance Platform struct

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-05-26 08:40:42 +00:00
Tunglies f9ffe0af8e chore: update pr-ai-slop-review 2026-05-26 16:46:44 +08:00
Tunglies c371f95815 chore: fix minumReleaaseAgeExclude 2026-05-26 16:34:11 +08:00
Tunglies 2e0c783e66 chore: update Makefile 2026-05-26 14:34:40 +08:00
StarsSail 512efe7d41 fix: node selection for remembered profiles during switch (#6865)
* fix: node selection for remembered profiles during switch

* remove redundant comments
2026-05-26 06:20:02 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 28139706d2 chore(deps): update npm dependencies (#7121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 06:14:49 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8752130f75 chore(deps): update cargo dependencies (#7109)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 06:14:38 +00:00
Tunglies 32ed10c25d chore: remove node-fetch dependency from package.json and related scripts 2026-05-26 14:03:59 +08:00
Tunglies c7cdc62512 chore: bump dependencies
Bump tauri-plugin-mihomo to 0.5.0 and update frontend dependency locks.

Adapt the traffic websocket stream parser to the new raw text byte callback API.
2026-05-26 13:30:37 +08:00
Tunglies 435d611114 chore: update pnpm peers 2026-05-26 12:47:59 +08:00
Tunglies 29fc8c9f4c chore: bump pnpm to 11 (#7125) 2026-05-26 12:24:47 +08:00
Tunglies 746279fa03 refactor: remove subscription TLS roots fallback 2026-05-26 00:23:55 +08:00
Tunglies e2d14a5ea7 fix: support empty basic auth passwords 2026-05-25 23:12:44 +08:00
Tunglies d75c4cf59e fix: enable gzip subscription responses
docs: update changelog for gzip subscriptions
2026-05-25 22:59:11 +08:00
Tunglies ec9ef0f85e fix: report legacy TLS subscription failures 2026-05-25 22:22:24 +08:00
Tunglies 8ccb0b9abd perf: reduce tokio runtime thread limits
The Tauri backend is primarily the control plane for IPC, config generation, tray/window events, and mihomo API calls. The proxy data plane runs in the mihomo sidecar/service process, so allowing up to 16 runtime workers adds scheduling headroom without clear startup benefit.

Setup benchmark used isolated HOME/XDG dirs, isolated mihomo IPC sockets, sidecar mode, disabled TUN/system proxy defaults, and graceful shutdown after setup on an 18-thread host.

| limit | workers | blocking | setup async avg/max | avg threads |
| --- | ---: | ---: | ---: | ---: |
| old | 16 | 64 | 742.2ms / 1163.4ms | 34.5 |
| old | 8 | 32 | 650.8ms / 680.9ms | 27.0 |
| old | 4 | 16 | 681.5ms / 708.1ms | 23.8 |
| new | 16 | 32 | 682.8ms / 747.0ms | 35.3 |
| new | 8 | 16 | 650.1ms / 662.4ms | 26.0 |
| new | 4 | 8 | 639.3ms / 645.5ms | 22.1 |

The 2x blocking pool reduced tail latency across the tested limits. Capping workers at 8 keeps enough concurrency for normal control-plane work while avoiding the 16-worker tail-latency outlier and reducing blocking-pool headroom from 64 to 16 on this host.
2026-05-25 17:24:06 +08:00
Tunglies 2a397d7ef6 fix(settings): load network interfaces in allow LAN dialog 2026-05-24 15:41:15 +08:00
wonfen aa478bfe1a docs: remove Promotion 2026-05-24 13:14:59 +08:00
Tunglies c18c23963d refactor: frontend (#7097)
* refactor: remove unused window show/hide event listeners

* refactor: deduplicate profile-changed event handling

* refactor: remove unused setupCloseListener from use-listen

* refactor: remove unused removeAllListeners from use-listen

* fix(backend): dedupe profile changed notification on import

* fix: avoid duplicate timer update completed event

* refactor(frontend): simplify proxy switch fallback path
2026-05-23 12:52:52 +08:00
Tunglies d3bb018ab7 fix: ai-slop setup 2026-05-23 12:50:15 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 95be5aa816 chore(deps): update github/gh-aw-actions action to v0.75.1 (#7096)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 12:46:25 +08:00
Tunglies 32029af0f9 refactor: improve tray speed rendering logic and attributes handling 2026-05-22 21:47:59 +08:00
Tunglies d2f09211dd chore: reoder clippy rules 2026-05-22 21:32:44 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> bdb5807d17 chore(deps): update github actions (#7064)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 11:17:51 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e25732eb9d chore(deps): update rust crate serde_json to 1.0.150 (#7084)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-22 11:04:24 +00:00
Tunglies 2c7877a574 fix: add condition to prevent bot-triggered PR AI slop review 2026-05-22 19:08:40 +08:00
Tunglies e48e24bb7e fix: enhance PR AI slop review criteria for clearer evaluation 2026-05-22 19:00:42 +08:00
Tunglies 21878ea936 Refactor timer management and remove delay_timer dependency (#7075)
* Refactor timer management and remove delay_timer dependency

The delay_timer spawned tokio threads of cores, which not controled by expected.

* refactor: improve logging messages in Timer implementation

* fix(timer): prevent potential panic by using saturating_mul for interval calculations

* refactor timer scheduler

* chore: translate timer logs to english

* fix timer clippy warnings

* fix profile auto update refresh

* fix duplicate profile update notification

* fix: prevent potential panic in lightweight timer setup

* fix profile update listener cleanup

* Revert "fix duplicate profile update notification"

This reverts commit 4aafa94d3e.
2026-05-22 04:30:48 +00:00
Tunglies 54d130a0c4 fix lint violations 2026-05-21 18:49:12 +08:00
Tamás SzabóandCopilot a08d625712 fix(ci): avoid generated PR checkout in AI slop workflow (#7076)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 18:35:59 +08:00
Tunglies bedcb8a07c fix: update tauri-plugin-mihomo and related dependencies
close #7073
2026-05-21 18:34:47 +08:00
Tamás SzabóandCopilot f7c9e36891 chore(ci): update gh-aw workflow tooling (#7072)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 09:01:58 +00:00
Tunglies a45610e534 fix: remove set -euo pipefail from pre-commit and pre-push scripts 2026-05-21 13:24:25 +08:00
Tunglies 8efbf9e7fd fix: clippy errors for rust 1.97 2026-05-21 12:48:48 +08:00
Tunglies c7a989b484 fix(macOS): improve tray speed rendering attributes 2026-05-21 12:41:51 +08:00
Tunglies d05ead9cdd chore: track tauri-plugin-mihomo main updates 2026-05-21 12:16:17 +08:00
AetherWing bdbc617aff fix: disable WebView2 general autofill (#7062) 2026-05-21 04:01:31 +00:00
AetherWing df92e5d95d fix(ci): resolve pnpm lint issues (#7063)
* fix(eslint): remove deprecated prefer-destructuring-assignment rule

* refactor(eslint): port destructuring rule to sukka
2026-05-20 20:10:46 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 0113086d2a chore(deps): update cargo dependencies (#7060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 21:26:20 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ba4ee35374 chore(deps): update npm dependencies (#6922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 21:26:03 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>TungliesTunglies
631c9d1b61 chore(deps): update cargo dependencies (#6932)
* chore(deps): update cargo dependencies

* MSRV 1.95.0

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
Co-authored-by: Tunglies <tunglies.dev@outlook.com>
2026-05-20 13:11:02 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 88f1927aa5 chore(deps): update npm dependencies (#6930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 13:10:42 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8c76e0f4f6 chore(deps): update github actions (#6933)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 13:09:34 +00:00
Tunglies 46fb817fe4 chore:bump version to 2.5.2 2026-05-20 14:27:32 +08:00
Tunglies a00c67e8ae chore: update changelog 2026-05-20 14:23:46 +08:00
Christine. 6ebdaaa7ff fix: switch node slowly on Windows (#7052) 2026-05-20 14:21:24 +08:00
wonfen 15de400f2f fix: align backup dialogs with in-app modal behavior 2026-05-20 10:24:11 +08:00
Tunglies 6f36613103 chore: bump version to 2.5.1 2026-05-19 14:01:41 +08:00
426 changed files with 37759 additions and 14146 deletions
+72 -14
View File
@@ -13,12 +13,19 @@ This is a **dispatcher agent** that routes your request to the appropriate speci
- **Creating new workflows**: Routes to `create` prompt
- **Updating existing workflows**: Routes to `update` prompt
- **Debugging workflows**: Routes to `debug` prompt
- **Debugging workflows**: Routes to `debug` prompt
- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt
- **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments
- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt
- **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes
- **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs
- **Rendering ASCII charts in markdown**: Routes to `asciicharts` guide — consult this whenever the workflow needs compact charts that render reliably in GitHub issues, comments, or discussions
- **CLI commands and triggering workflows**: Routes to `cli-commands` guide — consult this whenever the user asks how to run, compile, debug, or manage workflows from the command line, or when they need the MCP tool equivalent of a `gh aw` command
- **Reducing token consumption / cost optimization**: Routes to `token-optimization` guide — consult this whenever the user asks how to reduce token usage, lower costs, speed up workflows, or measure the impact of prompt changes with experiments
- **Choosing workflow architectures and design patterns**: Routes to `patterns` guide — consult this whenever the user asks for strategy, architecture, operating models, or pattern selection for agentic workflows
> [!IMPORTANT]
> For architecture/pattern-selection requests, load `https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md` first.
Workflows may optionally include:
@@ -30,7 +37,7 @@ Workflows may optionally include:
- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md`
- Workflow lock files: `.github/workflows/*.lock.yml`
- Shared components: `.github/workflows/shared/*.md`
- Configuration: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md
- Configuration: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md
## Problems This Solves
@@ -52,27 +59,27 @@ When you interact with this agent, it will:
### Create New Workflow
**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-agentic-workflow.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-agentic-workflow.md
**Use cases**:
- "Create a workflow that triages issues"
- "I need a workflow to label pull requests"
- "Design a weekly research automation"
### Update Existing Workflow
### Update Existing Workflow
**Load when**: User wants to modify, improve, or refactor an existing workflow
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/update-agentic-workflow.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/update-agentic-workflow.md
**Use cases**:
- "Add web-fetch tool to the issue-classifier workflow"
- "Update the PR reviewer to use discussions instead of issues"
- "Improve the prompt for the weekly-research workflow"
### Debug Workflow
### Debug Workflow
**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/debug-agentic-workflow.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/debug-agentic-workflow.md
**Use cases**:
- "Why is this workflow failing?"
@@ -82,7 +89,7 @@ When you interact with this agent, it will:
### Upgrade Agentic Workflows
**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/upgrade-agentic-workflows.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/upgrade-agentic-workflows.md
**Use cases**:
- "Upgrade all workflows to the latest version"
@@ -92,7 +99,7 @@ When you interact with this agent, it will:
### Create a Report-Generating Workflow
**Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/report.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/report.md
**Use cases**:
- "Create a weekly CI health report"
@@ -102,7 +109,7 @@ When you interact with this agent, it will:
### Create Shared Agentic Workflow
**Load when**: User wants to create a reusable workflow component or wrap an MCP server
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/create-shared-agentic-workflow.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/create-shared-agentic-workflow.md
**Use cases**:
- "Create a shared component for Notion integration"
@@ -112,7 +119,7 @@ When you interact with this agent, it will:
### Fix Dependabot PRs
**Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`)
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/dependabot.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/dependabot.md
**Use cases**:
- "Fix the open Dependabot PRs for npm dependencies"
@@ -122,13 +129,58 @@ When you interact with this agent, it will:
### Analyze Test Coverage
**Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy.
**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/test-coverage.md
**Prompt file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/test-coverage.md
**Use cases**:
- "Create a workflow that comments coverage on PRs"
- "Analyze coverage trends over time"
- "Add a coverage gate that blocks PRs below a threshold"
### Render ASCII Charts in Markdown
**Load when**: The workflow needs in-markdown charts (sparklines, bars, table+trend views) that must align cleanly and render reliably across GitHub surfaces, including mobile.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/asciicharts.md
**Use cases**:
- "Show a compact trend chart in an issue comment"
- "Render a dashboard table with sparkline trends"
- "Generate aligned ASCII bars for service metrics"
### CLI Commands Reference
**Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md
**Use cases**:
- "How do I trigger workflow X on the main branch?"
- "What's the MCP equivalent of `gh aw logs`?"
- "I'm in Copilot Cloud — how do I compile a workflow?"
- "Show me all available gh aw commands"
### Token Consumption Optimization
**Load when**: The user asks how to reduce token usage, lower workflow costs, make a workflow faster or cheaper, or measure the impact of prompt or configuration changes.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/token-optimization.md
**Use cases**:
- "How do I reduce the token cost of this workflow?"
- "My workflow is too expensive — how do I optimize it?"
- "How do I compare token usage between two runs?"
- "Should I use gh-proxy or the MCP server?"
- "How do I use sub-agents to reduce costs?"
- "How do I measure the impact of a prompt change?"
### Workflow Pattern Selection
**Load when**: The user asks for architecture, strategy, operating model selection, or pattern recommendations for building agentic workflows.
**Reference file**: https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/patterns.md
**Use cases**:
- "Which pattern should I use for multi-repo rollout?"
- "How should I structure this workflow architecture?"
- "What pattern fits slash-command triage?"
- "Should this be DispatchOps or DailyOps?"
## Instructions
When a user interacts with you:
@@ -147,6 +199,10 @@ gh aw init
# Generate the lock file for a workflow
gh aw compile [workflow-name]
# Trigger a workflow on demand (preferred over gh workflow run)
gh aw run <workflow-name> # interactive input collection
gh aw run <workflow-name> --ref main # run on a specific branch
# Debug workflow runs
gh aw logs [workflow-name]
gh aw audit <run-id>
@@ -169,10 +225,12 @@ gh aw compile --validate
## Important Notes
- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/github-agentic-workflows.md for complete documentation
- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/github-agentic-workflows.md for complete documentation
- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud
- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions
- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF
- Follow security best practices: minimal permissions, explicit network access, no template injection
- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.68.1/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns.
- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns.
- **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself.
- **Triggering runs**: Always use `gh aw run <workflow-name>` to trigger a workflow on demand — not `gh workflow run <file>.lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref <branch>` to run on a specific branch.
- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see https://github.com/github/gh-aw/blob/v0.74.8/.github/aw/cli-commands.md
+59 -7
View File
@@ -3,17 +3,69 @@
"actions/github-script@v9.0.0": {
"repo": "actions/github-script",
"version": "v9.0.0",
"sha": "d746ffe35508b1917358783b479e04febd2b8f71"
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.68.1": {
"github/gh-aw-actions/setup@v0.83.4": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.68.1",
"sha": "2fe53acc038ba01c3bbdc767d4b25df31ca5bdfc"
"version": "v0.83.4",
"sha": "e89c65e17eb281bbd5ff2ff9e9199a03e96654c7"
},
"github/gh-aw/actions/setup@v0.68.2": {
"github/gh-aw/actions/setup@v0.74.8": {
"repo": "github/gh-aw/actions/setup",
"version": "v0.68.2",
"sha": "265e150164f303f0ea34d429eecd2d66ebe1d26f"
"version": "v0.74.8",
"sha": "0feed75a980b06f247abbbf80127f8eb2c19e2c5"
}
},
"containers": {
"ghcr.io/github/gh-aw-firewall/agent:0.25.18": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.25.18",
"digest": "sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2"
},
"ghcr.io/github/gh-aw-firewall/agent:0.25.49": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.25.49",
"digest": "sha256:9e4b936d4215af09fa412e12a5fc82f97f2cde4993eefc85980a900122fa9062",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.25.49@sha256:9e4b936d4215af09fa412e12a5fc82f97f2cde4993eefc85980a900122fa9062"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18",
"digest": "sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49",
"digest": "sha256:b795506f6b4fd12694a29f964b4d38d2d29ac3aaafe3394131619ce36020b646",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49@sha256:b795506f6b4fd12694a29f964b4d38d2d29ac3aaafe3394131619ce36020b646"
},
"ghcr.io/github/gh-aw-firewall/squid:0.25.18": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.25.18",
"digest": "sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e"
},
"ghcr.io/github/gh-aw-firewall/squid:0.25.49": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.25.49",
"digest": "sha256:f6c6998edfb9f58b6e12d3f148e6f2104747a7702d563a9e8b13d4b2ae6997dd",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.25.49@sha256:f6c6998edfb9f58b6e12d3f148e6f2104747a7702d563a9e8b13d4b2ae6997dd"
},
"ghcr.io/github/gh-aw-mcpg:v0.2.17": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.2.17",
"digest": "sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.2.17@sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8"
},
"ghcr.io/github/github-mcp-server:v0.32.0": {
"image": "ghcr.io/github/github-mcp-server:v0.32.0",
"digest": "sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28",
"pinned_image": "ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28"
},
"ghcr.io/github/github-mcp-server:v1.0.4": {
"image": "ghcr.io/github/github-mcp-server:v1.0.4",
"digest": "sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4"
},
"node:lts-alpine": {
"image": "node:lts-alpine",
"digest": "sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f",
"pinned_image": "node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"
}
}
}
+4 -1
View File
@@ -3,13 +3,16 @@ name: Autobuild Check Logic Test
on:
workflow_dispatch:
permissions:
contents: read
jobs:
check_autobuild_logic:
name: Check Autobuild Should Run Logic
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 2
+35 -39
View File
@@ -29,24 +29,26 @@ jobs:
runs-on: ubuntu-latest
needs: check_commit
if: ${{ needs.check_commit.outputs.should_run == 'true' }}
outputs:
release_id: ${{ steps.upload_release.outputs.id }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Fetch UPDATE logs
id: fetch_update_logs
run: bash ./scripts/extract_update_logs.sh
shell: bash
- uses: pnpm/action-setup@v6.0.4
- uses: pnpm/action-setup@v6.0.9
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -102,9 +104,11 @@ jobs:
EOF
- name: Upload Release
id: upload_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.TAG_NAME }}
target_commitish: ${{ github.sha }}
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
body_path: release.txt
prerelease: true
@@ -142,12 +146,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.91.0'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -170,28 +174,19 @@ jobs:
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install x86 OpenSSL (macOS only)
if: matrix.target == 'x86_64-apple-darwin'
run: |
arch -x86_64 brew install openssl@3
echo "OPENSSL_DIR=$(brew --prefix openssl@3)" >> $GITHUB_ENV
echo "OPENSSL_INCLUDE_DIR=$(brew --prefix openssl@3)/include" >> $GITHUB_ENV
echo "OPENSSL_LIB_DIR=$(brew --prefix openssl@3)/lib" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV
- uses: pnpm/action-setup@v6.0.4
- uses: pnpm/action-setup@v6.0.9
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
@@ -209,12 +204,12 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@v1
env:
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -227,6 +222,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.update_tag.outputs.release_id }}
tagName: ${{ env.TAG_NAME }}
releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
releaseBody: 'More new features are now supported.'
@@ -255,12 +251,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.91.0'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -278,18 +274,18 @@ jobs:
cache-workspace-crates: true
- name: Install pnpm
uses: pnpm/action-setup@v6.0.4
uses: pnpm/action-setup@v6.0.9
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
@@ -351,7 +347,7 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -406,7 +402,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
@@ -423,18 +419,18 @@ jobs:
cache-workspace-crates: true
- name: Install pnpm
uses: pnpm/action-setup@v6.0.4
uses: pnpm/action-setup@v6.0.9
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
@@ -459,13 +455,13 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows
id: build
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@v1
env:
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -522,7 +518,7 @@ jobs:
]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Fetch UPDATE logs
id: fetch_update_logs
@@ -530,11 +526,11 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6.0.4
- uses: pnpm/action-setup@v6.0.9
name: Install pnpm
with:
run_install: false
+4 -1
View File
@@ -3,6 +3,9 @@ name: Cargo Audit
on:
workflow_dispatch:
permissions:
contents: read
env:
HUSKY: 0
@@ -22,7 +25,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -53,7 +53,7 @@ jobs:
autobuild_version: ${{ steps.check.outputs.autobuild_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 50
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
autobuild_version: ${{ steps.check.outputs.autobuild_version }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 50
@@ -105,7 +105,7 @@ jobs:
needs: check_current_version
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Clean old assets from release
env:
-26
View File
@@ -1,26 +0,0 @@
name: "Copilot Setup Steps"
# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent
copilot-setup-steps:
runs-on: ubuntu-latest
# Set minimal permissions for setup steps
# Copilot Agent receives its own token with appropriate permissions
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
with:
version: v0.68.1
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
@@ -41,9 +41,9 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+7 -7
View File
@@ -70,11 +70,11 @@ jobs:
- name: Checkout Repository
if: github.event.inputs[matrix.input] == 'true'
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.91.0
uses: dtolnay/rust-toolchain@1.95.0
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -101,13 +101,13 @@ jobs:
- name: Install Node
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
@@ -129,13 +129,13 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
if: github.event.inputs[matrix.input] == 'true'
uses: tauri-apps/tauri-action@v0
uses: tauri-apps/tauri-action@v1
env:
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+16 -4
View File
@@ -4,6 +4,9 @@ on:
pull_request:
workflow_dispatch:
permissions:
contents: read
env:
HUSKY: 0
@@ -11,7 +14,7 @@ jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Check frontend changes
id: check_frontend
@@ -30,6 +33,7 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'knip.json'
- 'eslint.config.ts'
- 'tsconfig.json'
- 'vite.config.*'
@@ -44,15 +48,15 @@ jobs:
with:
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.15.0'
node-version: '24.18.0'
cache: 'pnpm'
- name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
@@ -73,3 +77,11 @@ jobs:
- name: Run TypeScript typecheck
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
+8 -1
View File
@@ -5,6 +5,8 @@ on:
workflow_dispatch:
env:
HUSKY: 0
permissions:
contents: read
jobs:
clippy:
@@ -44,7 +46,7 @@ jobs:
echo "Manual trigger detected: skipping changes check and running clippy."
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
@@ -76,6 +78,11 @@ jobs:
working-directory: ./src-tauri
run: cargo clippy-all
- name: Run Windows security tests
if: matrix.os == 'windows-latest' && (github.event_name == 'workflow_dispatch' || steps.check_changes.outputs.rust == 'true')
working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }}
- name: Run Logging Check
working-directory: ./src-tauri
shell: bash
File diff suppressed because one or more lines are too long
+39 -5
View File
@@ -6,18 +6,19 @@ description: |
on:
roles: all
skip-bots: [dependabot, renovate]
pull_request_target:
types: [opened, reopened, synchronize]
workflow_dispatch:
checkout: false
permissions:
contents: read
issues: read
pull-requests: read
tools:
github:
toolsets: [default]
toolsets: [issues, pull_requests]
lockdown: false
min-integrity: unapproved
@@ -48,6 +49,8 @@ This workflow is not a technical code reviewer. Do not judge correctness, archit
- AI assistance by itself is not a problem.
- Domain Isolation, do not let the author's personal background, hobbies, or professional titles influence the risk score. High-quality code is its own evidence; poor logic cannot be excused by status.
- Missing issue linkage is a strong negative signal.
- Retroactive issue linkage, PR body edits, or explanatory comments may reduce only the missing-linkage concern. They must not erase one-shot structural evidence unless accompanied by new commits that show real implementation iteration, scope reduction, or reviewer-directed code changes.
- Existing AI-slop labels must not be downgraded on a rerun for the same head commit unless there is new substantive code evidence or maintainer-provided context that directly disproves the earlier risk assessment.
- Always leave exactly one comment on the PR.
- Always remove stale AI-slop labels before adding a replacement label.
- Keep the tone factual, calm, and maintainership-oriented.
@@ -63,6 +66,7 @@ Use GitHub tools to inspect the triggering pull request in full:
- PR author association, repository role signals, and visible ownership history when available
- Changed files and diff shape
- Existing review comments and author replies when available
- Existing AI-slop labels and earlier AI-slop review comments, especially whether the current run is evaluating the same head commit as a previous run
If the PR references an issue, inspect that issue as well and compare the stated problem with the actual scope of the code changes.
@@ -70,26 +74,48 @@ If the PR references an issue, inspect that issue as well and compare the stated
- No referenced issue, or only vague claims like "fixes multiple issues" without a concrete issue number
- Single large commit or a very small number of commits covering many unrelated areas
- One-shot feature burst: a non-trivial feature delivered in one commit across many files or multiple subsystems, especially when the change is hundreds or thousands of lines and includes backend, frontend, settings, logging, and service-layer edits together
- Implementation-first linkage: the referenced issue was created after the PR was opened, after an AI-slop review comment, or after maintainers requested issue linkage
- Metadata-only remediation: the author edits the PR body, adds a retroactive issue, comments an explanation, closes/reopens the PR, or otherwise retriggers the workflow without adding commits that change the implementation
- PR body reads like a generated report rather than a maintainer-owned change description
- PR body or author replies use stock report sections such as "Root Cause", "Root Case", "Checklist", or "Check List" without concrete issue evidence, reproduction context, or project-specific reasoning
- PR body includes duplicated or performative testing claims, such as both "Test" and "Testing" sections, repeated verification language, or generic lint/static-analysis output that does not explain how the reported issue was reproduced or validated.
- Explicit AI provenance links or bot-authored commits from coding agents
- Large-scale mechanical edits with little behavioral justification
- Random renames, comment rewrites, or same-meaning text changes that do not support the fix
- Ghost Comments, code comments that explain the obvious (e.g., explaining a variable name) or use AI-typical hedging language.
- Code comments that restate obvious behavior or narrate trivial operations without contributing repository-specific reasoning
- New tests that are generic, padded, or not clearly connected to the reported issue
- Scope Drift, the PR claims to fix a specific bug but touches unrelated modules, config files, or documentation without justification.
- Draft or vague "ongoing optimization" style PRs with broad churn and weak problem statement
- Lint/Static Analysis Padding: The PR body includes exhaustive, unnecessary listings of "Lint" results, static analysis summaries, or tool outputs intended to pad the appearance of rigor without providing actual insight without explaining how these results directly relate to the reported issue.
- Performative Verification/Testing: The PR contains elaborate "Verify" or "Test" sections that look structurally impressive (using templated checklists or fancy headers) but lack project-specific context, concrete reproduction steps, or meaningful assertions of correctness or where the 'Test' steps are just a generic echo of the implementation code..
- Branch names that clearly match AI-agent workflow patterns, ephemeral execution IDs, or automated coding-session naming conventions.
## Hard Escalation Rules
- If the pull request branch name clearly appears to be generated by an AI coding workflow, autonomous coding agent, or mass-generated automation pipeline (for example: generic agent-run IDs, Copilot/Cursor/Windsurf/Codex/Claude Code style ephemeral task branches, or machine-formatted execution/session identifiers), treat the PR as likely-one-shot-ai with high confidence regardless of the diff quality or linked issue state.
- This override exists because the submission flow itself indicates weak evidence of direct human ownership and iterative stewardship.
- If a PR combines all of these structural signals, treat it as likely-one-shot-ai with high confidence even when the later PR body or a later linked issue is detailed: one large commit, a broad feature touching multiple subsystems, no pre-existing issue or prior problem discussion at PR open time, report-style validation or generated-looking body text, and no later implementation commits showing real iteration.
- If a rerun evaluates the same head commit as a previous AI-slop run, do not lower an existing `ai-slop:med` or `ai-slop:high` label based only on retroactive issue linkage, PR body edits, author comments, close/reopen activity, or clearer prose. Preserve or escalate the label unless new commits materially change the ownership evidence.
Do not claim the PR is AI-generated as a fact unless explicitly disclosed. Frame conclusions in terms of likelihood and ownership-risk assessment.
## Counter-Signals
- Clear issue linkage with a concrete bug report or feature request
- Clear issue linkage with a concrete bug report or feature request that existed before the PR was opened, or that is backed by visible prior discussion predating the implementation
- Tight file scope that matches the linked issue
- Commits that show iteration, review response, or narrowing of scope
- Tests that directly validate the reported regression or expected behavior
- Clear explanation of why each changed area is necessary for the fix
- Cross-Contextual Logic, the author explains *why* a change was made in a way that shows understanding of the project's specific constraints, rather than just repeating the issue text.
- Report-style sections are backed by concrete reproduction steps, failure evidence, or repository-specific constraints; template-required checklists should not count as a slop signal by themselves
- Incremental commit evolution where later commits refine or partially revert earlier assumptions instead of only appending generated output
- Reviewer interaction that changes implementation direction, scope, or reasoning in response to feedback. Comments, PR body edits, and retroactive issue creation without implementation commits are weak counter-signals only.
- Explicit tradeoff discussion tied to repository-specific constraints, historical behavior, or compatibility concerns
- Small corrective follow-up commits that indicate active maintenance rather than one-pass generation
- Diffs that preserve existing project conventions even when alternative "cleaner" patterns exist
- Evidence that the author investigated prior behavior, regressions, or historical implementation choices before modifying code
- Evidence of established repository ownership or ongoing stewardship may reduce slop likelihood, but must never be disclosed in the public comment
- References to prior repository behavior, historical regressions, earlier PRs, or subsystem-specific constraints that are not obvious from the current diff alone
- Report-style sections should be discounted when they match this repository's own PR template or established maintainer convention.
## Decision Rules
@@ -105,12 +131,20 @@ Then choose exactly one confidence level for AI-slop likelihood:
- `medium`: enough evidence to apply `ai-slop:med`
- `high`: enough evidence to apply `ai-slop:high`
Confidence calibration:
- Use `high` for strong structural one-shot evidence, even without explicit AI disclosure.
- Use `high` for broad one-commit feature submissions that look implementation-first and are later backfilled with issue linkage or polished prose.
- Use `medium` for incomplete ownership evidence when the scope is smaller, the change is narrow, or there are some genuine implementation-iteration signals.
- Use `low` only when the one-shot evidence is weak, or when new code commits/reviewer-driven changes materially demonstrate human ownership.
Label handling rules:
- Always remove any existing AI-slop confidence labels first.
- If confidence is `medium`, add only `ai-slop:med`.
- If confidence is `high`, add only `ai-slop:high`.
- If confidence is `low`, do not add either label after cleanup.
- When the same head commit already had `ai-slop:med` or `ai-slop:high`, the new confidence must not be lower unless new commits or maintainer context justify the downgrade. In that case, keep the previous label outcome instead of clearing it.
## Commenting Rules
+82 -83
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -63,13 +63,38 @@ jobs:
echo "✅ Tag and package.json version are consistent."
prepare_release:
name: Prepare Draft Release
runs-on: ubuntu-latest
needs: [check_tag_version]
outputs:
release_id: ${{ steps.prepare_release.outputs.id }}
steps:
- name: Create or update draft release
id: prepare_release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
name: 'Clash Verge Rev ${{ github.ref_name }}'
body: 'Draft release, will be updated later.'
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
update_tag:
name: Update tag
runs-on: ubuntu-latest
needs: [release, release-for-linux-arm, release-for-fixed-webview2]
needs:
[
prepare_release,
release,
release-for-linux-arm,
release-for-fixed-webview2,
]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Fetch UPDATE logs
id: fetch_update_logs
@@ -130,21 +155,21 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
run: |
matches=$(gh api repos/{owner}/{repo}/releases --paginate --jq '.[] | select(.tag_name == env.TAG_NAME) | [.id, .draft, (.assets | length), .html_url] | @tsv')
release_count=$(grep -c . <<< "$matches" || true)
release_id="${{ needs.prepare_release.outputs.release_id }}"
if [ "$release_count" -eq 0 ]; then
echo "::error::No release found for $TAG_NAME."
if [ -z "$release_id" ]; then
echo "::error::No prepared release ID found for $TAG_NAME."
exit 1
fi
if [ "$release_count" -gt 1 ]; then
printf '%s\n' "$matches"
echo "::error::Multiple releases found for $TAG_NAME. Merge or delete duplicate releases before rerunning."
release_info=$(gh api repos/{owner}/{repo}/releases/"$release_id" --jq '[.tag_name, .draft, (.assets | length), .html_url] | @tsv')
IFS=$'\t' read -r release_tag _ asset_count release_url <<< "$release_info"
if [ "$release_tag" != "$TAG_NAME" ]; then
echo "::error::Prepared release $release_id belongs to $release_tag, expected $TAG_NAME."
exit 1
fi
IFS=$'\t' read -r release_id _ asset_count release_url <<< "$matches"
echo "Publishing release $release_id ($release_url) with $asset_count assets."
gh api --method PATCH repos/{owner}/{repo}/releases/"$release_id" \
@@ -156,7 +181,7 @@ jobs:
release:
name: Release Build
needs: [check_tag_version]
needs: [prepare_release]
strategy:
fail-fast: false
matrix:
@@ -175,12 +200,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.91.0'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -203,19 +228,10 @@ jobs:
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install x86 OpenSSL (macOS only)
if: matrix.target == 'x86_64-apple-darwin'
run: |
arch -x86_64 brew install openssl@3
echo "OPENSSL_DIR=$(brew --prefix openssl@3)" >> $GITHUB_ENV
echo "OPENSSL_INCLUDE_DIR=$(brew --prefix openssl@3)/include" >> $GITHUB_ENV
echo "OPENSSL_LIB_DIR=$(brew --prefix openssl@3)/lib" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -230,13 +246,13 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v0.6.2
uses: tauri-apps/tauri-action@v1.0.0
env:
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -249,11 +265,9 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ github.ref_name }}
releaseName: 'Clash Verge Rev ${{ github.ref_name }}'
releaseBody: 'Draft release, will be updated later.'
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
tauriScript: pnpm
args: --target ${{ matrix.target }}
includeUpdaterJson: true
@@ -280,7 +294,7 @@ jobs:
release-for-linux-arm:
name: Release Build for Linux ARM
needs: [check_tag_version]
needs: [prepare_release]
strategy:
fail-fast: false
matrix:
@@ -294,12 +308,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.91.0'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -317,9 +331,9 @@ jobs:
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
@@ -378,7 +392,7 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
@@ -398,13 +412,6 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Get Version
run: |
sudo apt-get update
sudo apt-get install jq
echo "VERSION=$(cat package.json | jq '.version' | tr -d '"')" >> $GITHUB_ENV
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
- name: Attest Linux bundles
uses: actions/attest-build-provenance@v4
with:
@@ -412,22 +419,18 @@ jobs:
target/${{ matrix.target }}/release/bundle/deb/*.deb
target/${{ matrix.target }}/release/bundle/rpm/*.rpm
- name: Upload Release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{env.VERSION}}
name: 'Clash Verge Rev v${{env.VERSION}}'
body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
files: |
target/${{ matrix.target }}/release/bundle/deb/*.deb
target/${{ matrix.target }}/release/bundle/rpm/*.rpm
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber
release-for-fixed-webview2:
name: Release Build for Fixed WebView2
needs: [check_tag_version]
needs: [prepare_release]
strategy:
fail-fast: false
matrix:
@@ -441,12 +444,12 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.91.0'
toolchain: '1.97.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -464,9 +467,9 @@ jobs:
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -488,13 +491,12 @@ jobs:
- name: Add Rust Target
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.91.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.95.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
id: build
uses: tauri-apps/tauri-action@v0.6.2
uses: tauri-apps/tauri-action@v1.0.0
env:
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -529,16 +531,13 @@ jobs:
with:
subject-path: target/${{ matrix.target }}/release/bundle/nsis/*setup*
- name: Upload Release
uses: softprops/action-gh-release@v3
with:
tag_name: v${{steps.build.outputs.appVersion}}
name: 'Clash Verge Rev v${{steps.build.outputs.appVersion}}'
body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName
gh release upload "${{ github.ref_name }}" $files --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Portable Bundle
run: pnpm portable-fixed-webview2 ${{ matrix.target }}
@@ -552,12 +551,12 @@ jobs:
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -578,12 +577,12 @@ jobs:
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -605,7 +604,7 @@ jobs:
needs: [update_tag, release-update]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Get Version
@@ -635,7 +634,7 @@ jobs:
]
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Fetch UPDATE logs
id: fetch_update_logs
@@ -643,9 +642,9 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+4 -1
View File
@@ -7,6 +7,9 @@ name: Check Formatting
on:
pull_request:
permissions:
contents: read
env:
HUSKY: 0
@@ -14,7 +17,7 @@ jobs:
rustfmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Check Rust changes
id: check_rust
@@ -1,78 +0,0 @@
name: Sync Mihomo Changelog
on:
workflow_dispatch:
schedule:
# UTC+8 Monday 11:30
- cron: '30 3 * * 1'
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-mihomo-changelog
cancel-in-progress: false
jobs:
sync:
name: Sync Changelog
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ github.event.repository.default_branch }}
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: '24.15.0'
- name: Sync Mihomo version
run: node scripts/sync-mihomo-version.mjs
- name: Detect changelog changes
id: changes
run: |
if git diff --quiet -- Changelog.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mihomo_version="$(grep -Eo 'Mihomo\(Meta\).*v[0-9]+(\.[0-9]+)+' Changelog.md | grep -Eo 'v[0-9]+(\.[0-9]+)+' | head -n 1)"
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "mihomo_version=$mihomo_version" >> "$GITHUB_OUTPUT"
- name: Open pull request
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BASE_BRANCH: ${{ github.event.repository.default_branch }}
MIHOMO_VERSION: ${{ steps.changes.outputs.mihomo_version }}
run: |
branch="chore/sync-mihomo-changelog"
title="chore: sync Mihomo changelog to ${MIHOMO_VERSION}"
body="Automated update for the Mihomo core version recorded in Changelog.md."
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin "$branch:refs/remotes/origin/$branch" || true
git checkout -B "$branch"
git add Changelog.md
git commit -m "$title"
git push --force-with-lease origin "$branch"
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
gh pr edit "$branch" \
--repo "$GITHUB_REPOSITORY" \
--title "$title" \
--body "$body"
else
gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--head "$branch" \
--title "$title" \
--body "$body"
fi
+3 -3
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Fetch UPDATE logs
id: fetch_update_logs
@@ -32,9 +32,9 @@ jobs:
shell: bash
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+6 -6
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -34,12 +34,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Install Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24.15.0'
node-version: '24.18.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+3 -1
View File
@@ -15,5 +15,7 @@ scripts/_env.sh
target
CLAUDE.md
.vfox.toml
.vfox/
.vfox
.claude
AGENTS.md
.zed
-1
View File
@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
if ! command -v "cargo-make" >/dev/null 2>&1; then
echo "❌ cargo-make is required for pre-commit checks."
-1
View File
@@ -1,5 +1,4 @@
#!/bin/bash
set -euo pipefail
if ! command -v "cargo-make" >/dev/null 2>&1; then
echo "❌ cargo-make is required for pre-push checks."
Generated
+990 -1597
View File
File diff suppressed because it is too large Load Diff
+69 -59
View File
@@ -53,87 +53,97 @@ clash-verge-i18n = { path = "crates/clash-verge-i18n" }
clash-verge-limiter = { path = "crates/clash-verge-limiter" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tauri = { version = "2.10.3" }
tauri = { version = "2.11.5" }
tauri-plugin-clipboard-manager = "2.3.2"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.102"
anyhow = "1.0.104"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.50.0", features = [
tokio = { version = "1.53.1", features = [
"rt-multi-thread",
"macros",
"time",
"sync",
] }
flexi_logger = "0.31.8"
log = "0.4.29"
flexi_logger = "0.31.9"
log = "0.4.33"
smartstring = { version = "1.0.1" }
compact_str = { version = "0.9.0", features = ["serde"] }
compact_str = { version = "0.10.0", features = ["serde"] }
serde = { version = "1.0.228" }
serde_json = { version = "1.0.149" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.11.0" }
bitflags = { version = "2.13.1" }
socket2 = "0.6.5"
# *** For Windows platform only ***
deelevate = "0.2.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_JobObjects"
] }
# *********************************
[workspace.lints.clippy]
correctness = { level = "deny", priority = -1 }
suspicious = { level = "deny", priority = -1 }
unwrap_used = "warn"
expect_used = "warn"
panic = "deny"
unimplemented = "deny"
todo = "warn"
dbg_macro = "warn"
clone_on_ref_ptr = "warn"
rc_clone_in_vec_init = "warn"
large_stack_arrays = "warn"
large_const_arrays = "warn"
# fallible_impl_from = "deny" // 过于激进,暂时不开启
# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启
# option_if_let_else = "deny" // 过于激进,暂时不开启
# self_only_used_in_recursion = "deny" // Since 1.92.0
async_yields_async = "deny"
await_holding_lock = "deny"
branches_sharing_code = "deny"
clone_on_ref_ptr = "warn"
cloned_instead_of_copied = "deny"
cognitive_complexity = "deny"
collection_is_never_read = "deny"
copy_iterator = "deny"
correctness = { level = "deny", priority = -1 }
dbg_macro = "warn"
equatable_if_let = "deny"
expect_used = "warn"
expl_impl_clone_on_copy = "deny"
future_not_send = "deny"
iter_on_empty_collections = "deny"
iter_on_single_items = "deny"
iter_with_drain = "deny"
large_const_arrays = "warn"
large_futures = "deny"
large_stack_arrays = "warn"
large_types_passed_by_value = "deny"
missing_const_for_fn = "deny"
mutex_atomic = "deny"
mutex_integer = "deny"
rc_mutex = "deny"
unused_async = "deny"
await_holding_lock = "deny"
large_futures = "deny"
future_not_send = "deny"
redundant_else = "deny"
needless_continue = "deny"
needless_raw_string_hashes = "deny"
or_fun_call = "deny"
cognitive_complexity = "deny"
useless_let_if_seq = "deny"
use_self = "deny"
tuple_array_conversions = "deny"
trait_duplication_in_bounds = "deny"
suspicious_operation_groupings = "deny"
string_lit_as_bytes = "deny"
significant_drop_tightening = "deny"
significant_drop_in_scrutinee = "deny"
redundant_clone = "deny"
# option_if_let_else = "deny" // 过于激进,暂时不开启
needless_pass_by_ref_mut = "deny"
needless_collect = "deny"
missing_const_for_fn = "deny"
iter_with_drain = "deny"
iter_on_single_items = "deny"
iter_on_empty_collections = "deny"
# fallible_impl_from = "deny" // 过于激进,暂时不开启
equatable_if_let = "deny"
collection_is_never_read = "deny"
branches_sharing_code = "deny"
pathbuf_init_then_push = "deny"
needless_continue = "deny"
needless_pass_by_ref_mut = "deny"
needless_raw_string_hashes = "deny"
option_as_ref_cloned = "deny"
large_types_passed_by_value = "deny"
# implicit_clone = "deny" // 可能会造成额外开销,暂时不开启
expl_impl_clone_on_copy = "deny"
copy_iterator = "deny"
cloned_instead_of_copied = "deny"
# self_only_used_in_recursion = "deny" // Since 1.92.0
or_fun_call = "deny"
panic = "deny"
pathbuf_init_then_push = "deny"
rc_clone_in_vec_init = "warn"
rc_mutex = "deny"
redundant_clone = "deny"
redundant_else = "deny"
significant_drop_in_scrutinee = "deny"
significant_drop_tightening = "deny"
string_lit_as_bytes = "deny"
suspicious = { level = "deny", priority = -1 }
suspicious_operation_groupings = "deny"
todo = "warn"
trait_duplication_in_bounds = "deny"
tuple_array_conversions = "deny"
unimplemented = "deny"
unnecessary_self_imports = "deny"
unused_trait_names = "deny"
wildcard_imports = "deny"
unnecessary_wraps = "deny"
unused_async = "deny"
unused_trait_names = "deny"
unwrap_used = "warn"
use_self = "deny"
useless_let_if_seq = "deny"
wildcard_imports = "deny"
+20 -26
View File
@@ -1,35 +1,29 @@
## v2.5.0
> [!IMPORTANT]
> 关于版本的说明:Clash Verge 版本号遵循 x.y.z:x 为重大架构变更,y 为功能新增,z 为 Bug 修复。
- **Mihomo(Meta) 内核升级至 v1.19.25**
## v2.5.3
### 🐞 修复问题
- 修复系统代理关闭后在 PAC 模式下未完全关闭
- 修复 macOS 开关代理时可能的卡死
- 修复修改定时自动更新后记时未及时刷新
- 修复 Linux 关闭 TUN 不立即生效
- 修复系统代理关闭序列逻辑(防止快速退出时系统代理关闭状态没有保存)
- 修复 Linux 快捷键映射错误
- 修复 Linux Wayland 兼容性导致渲染错误
- 修复 Linux 边缘情况无法加载页面
修复 macOS 多显示器切换时托盘速率样式不同步
修复 WebSocket 在 onConnected 初始化完成前被保存的问题,避免初始化失败后留下无监听且无法重试的无效连接。
修复 Mihomo API 变更后 inline proxy-provider 被错误过滤的问题,解决节点显示 Unknown 及延迟测试失败的问题
### ✨ 新增功能
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 订阅 QR code 分享
- 新增 macOS 托盘速率显示
- 快捷键操作通知操作结果
- 软件自动更新(后台下载,下次启动自动安装)
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
### 🚀 优化改进
</details>
- 优化 macOS 读取系统代理性能
- 优化前端 CPU 性能
- 更健壮的服务模式与边缘状况内核恢复
- 优化白名单网络下的订阅 TLS 更新兼容性
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
### 👙 界面样式
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
- 优化监听端口冲突检测:代理端口按最终 Runtime 监听范围校验并协调三层持久化,隧道按实际地址与 TCP/UDP 协议检测且保留热重载
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
- 统一系统代理所有权,实现原子化代理切换
- 新增旧版 Service 迁移引导与 Sidecar 安全回退
- 优化核心启动、停止及更新流程,减少竞态与残留进程
- 优化首页当前节点的交互
- 代理组实现 Sticky Scroll 效果
</details>
+81 -9
View File
@@ -7,6 +7,7 @@ skip_crate_env_info = true
# --- Backend ---
[tasks.rust-format]
description = "Run rustfmt on Rust code"
install_crate = "rustfmt"
command = "cargo"
args = ["fmt", "--", "--emit=files"]
@@ -22,32 +23,58 @@ args = ["clippy", "--all-targets", "--all-features", "--", "-D", "warnings"]
description = "Run type checks"
command = "pnpm"
args = ["typecheck"]
[tasks.typecheck.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "typecheck"]
[tasks.knip-report]
description = "Report unused frontend code and dependencies"
command = "pnpm"
args = ["knip:report"]
[tasks.knip-report.windows]
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "knip:report"]
[tasks.knip-check]
description = "Fail on unused frontend code and dependencies"
command = "pnpm"
args = ["knip:check"]
[tasks.knip-check.windows]
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "knip:check"]
[tasks.lint-staged]
description = "Run lint-staged for staged files"
command = "pnpm"
args = ["exec", "lint-staged"]
[tasks.lint-staged.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "exec", "lint-staged"]
[tasks.i18n-format]
description = "Format i18n keys"
command = "pnpm"
args = ["i18n:format"]
[tasks.i18n-format.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "i18n:format"]
[tasks.i18n-types]
description = "Generate i18n key types"
command = "pnpm"
args = ["i18n:types"]
[tasks.i18n-types.windows]
command = "pnpm.cmd"
command = "cmd.exe"
args = ["/d", "/c", "pnpm", "i18n:types"]
[tasks.git-add]
description = "Add changed files to git"
description = "Add generated files back to git index"
command = "git"
args = [
"add",
@@ -60,14 +87,59 @@ args = [
[tasks.frontend-format]
description = "Frontend format checks"
dependencies = ["i18n-format", "i18n-types", "git-add", "lint-staged"]
script_runner = "@duckscript"
script = '''
# i18n related changes
has_i18n = exec git diff --cached --quiet -- src/locales crates/clash-verge-i18n/locales
if eq ${has_i18n.code} 1
cm_run_task i18n-format
cm_run_task i18n-types
cm_run_task git-add
end
# frontend related changes
has_frontend = exec git diff --cached --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
if eq ${has_frontend.code} 1
cm_run_task lint-staged
end
'''
# --- Git Hooks ---
[tasks.pre-commit]
description = "Pre-commit checks: format only"
dependencies = ["rust-format", "frontend-format"]
script_runner = "@duckscript"
script = '''
# rust related changes
has_rust = exec git diff --cached --quiet -- "*.rs" Cargo.toml Cargo.lock
if eq ${has_rust.code} 1
cm_run_task rust-format
end
# frontend checks
cm_run_task frontend-format
'''
[tasks.pre-push]
description = "Pre-push checks: lint and typecheck"
dependencies = ["rust-clippy", "typecheck"]
description = "Pre-push checks: lint, typecheck, and unused files"
script_runner = "@duckscript"
script = '''
has_upstream = exec git rev-parse --abbrev-ref --symbolic-full-name @{u}
if not eq ${has_upstream.code} 0
echo "No upstream found, skipping diff-based checks"
exit 0
end
# rust related changes
has_rust = exec git diff @{u}..HEAD --quiet -- "*.rs" Cargo.toml Cargo.lock
if eq ${has_rust.code} 1
cm_run_task rust-clippy
end
# frontend related changes
has_frontend = exec git diff @{u}..HEAD --quiet -- "*.js" "*.jsx" "*.ts" "*.tsx" "*.json" "*.css" "*.scss" "*.html"
if eq ${has_frontend.code} 1
cm_run_task typecheck
cm_run_task knip-check
end
'''
+7 -1
View File
@@ -107,6 +107,12 @@ pnpm run prebuild
pnpm dev
```
`pnpm dev` preserves the Development Channel's installed service state: an
existing service is used, while a previously uninstalled service remains
uninstalled and the app starts in Sidecar mode. Use `pnpm dev:service` to
explicitly install or update the isolated development service before launch,
or `pnpm dev:sidecar` to force the unprivileged Sidecar workflow.
## Contributions
Issue and PR welcome!
@@ -124,4 +130,4 @@ Clash Verge rev was based on or inspired by these projects and so on:
## License
GPL-3.0 License. See [License here](./LICENSE) for details.
GPL-3.0 License. See [License here](./LICENSE) for details.
+9 -4
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"$schema": "node_modules/@biomejs/biome/configuration_schema.json",
"assist": {
"actions": {
"source": {
@@ -10,14 +10,18 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"preset": "recommended",
"suspicious": {
"noExplicitAny": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
"lineWidth": 80,
"lineEnding": "auto"
},
"javascript": {
"formatter": {
@@ -41,7 +45,8 @@
"!CONTRIBUTING.md",
"!.changelog_backups",
"!.github",
"!.pnpm-lock.yaml"
"!.pnpm-lock.yaml",
"!.vfox"
]
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ harness = false
[dependencies]
parking_lot = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
criterion = { workspace = true }
tokio = { workspace = true }
+206 -22
View File
@@ -1,27 +1,57 @@
use parking_lot::RwLock;
use parking_lot::Mutex;
use std::sync::Arc;
use std::sync::atomic::{
AtomicBool,
Ordering::{Acquire, Relaxed, Release},
};
use tokio::sync::Notify;
pub type SharedDraft<T> = Arc<T>;
type DraftInner<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储,
pub type SharedDraft<T> = Arc<Box<T>>;
// (committed_snapshot, optional_draft_snapshot)
type DraftData<T> = (SharedDraft<T>, Option<SharedDraft<T>>);
// Retry one scheduler turn before parking on Notify for long async updates.
const DATA_MODIFY_FAST_RETRY_YIELDS: usize = 1;
#[derive(Debug)]
struct DraftInner<T> {
data: Mutex<DraftData<T>>,
data_modifying: AtomicBool,
data_modify_notify: Notify,
/// Held by the one [`DraftTransaction`] currently allowed to stage a change.
claimed: AtomicBool,
}
struct DataModifyPermit<'a>(&'a AtomicBool, &'a Notify);
impl Drop for DataModifyPermit<'_> {
fn drop(&mut self) {
self.0.store(false, Release);
self.1.notify_one();
}
}
/// Draft 管理:committed 与 optional draft 都以 Arc<Box<T>> 存储。
#[derive(Debug, Clone)]
pub struct Draft<T> {
inner: Arc<RwLock<DraftInner<T>>>,
inner: Arc<DraftInner<T>>,
}
impl<T: Clone> Draft<T> {
#[inline]
pub fn new(data: T) -> Self {
Self {
inner: Arc::new(RwLock::new((Arc::new(data), None))),
inner: Arc::new(DraftInner {
data: Mutex::new((Arc::new(Box::new(data)), None)),
data_modifying: AtomicBool::new(false),
data_modify_notify: Notify::new(),
claimed: AtomicBool::new(false),
}),
}
}
/// 以 Arc<Box<T>> 的形式获取当前“已提交(正式)”数据的快照(零拷贝,仅 clone Arc)
#[inline]
pub fn data_arc(&self) -> SharedDraft<T> {
let guard = self.inner.read();
let guard = self.inner.data.lock();
Arc::clone(&guard.0)
}
@@ -29,7 +59,7 @@ impl<T: Clone> Draft<T> {
/// 这也是零拷贝:只 clone Arc,不 clone T
#[inline]
pub fn latest_arc(&self) -> SharedDraft<T> {
let guard = self.inner.read();
let guard = self.inner.data.lock();
guard.1.clone().unwrap_or_else(|| Arc::clone(&guard.0))
}
@@ -41,10 +71,10 @@ impl<T: Clone> Draft<T> {
where
F: FnOnce(&mut T) -> R,
{
let mut guard = self.inner.write();
let mut guard = self.inner.data.lock();
let mut draft_arc = guard.1.take().unwrap_or_else(|| Arc::clone(&guard.0));
let data_mut = Arc::make_mut(&mut draft_arc);
let result = f(data_mut);
let result = f(data_mut.as_mut());
guard.1 = Some(draft_arc);
result
}
@@ -52,7 +82,7 @@ impl<T: Clone> Draft<T> {
/// 将草稿提交到已提交位置(替换),并清除草稿
#[inline]
pub fn apply(&self) {
let mut guard = self.inner.write();
let mut guard = self.inner.data.lock();
if let Some(d) = guard.1.take() {
guard.0 = d;
}
@@ -61,7 +91,7 @@ impl<T: Clone> Draft<T> {
/// 丢弃草稿(如果存在)
#[inline]
pub fn discard(&self) {
let mut guard = self.inner.write();
let mut guard = self.inner.data.lock();
guard.1 = None;
}
@@ -74,27 +104,181 @@ impl<T: Clone> Draft<T> {
F: FnOnce(T) -> Fut + Send,
Fut: std::future::Future<Output = Result<(T, R), anyhow::Error>> + Send,
{
let _permit = self.acquire_data_modify_permit().await;
let (local, original_arc) = {
let guard = self.inner.read();
let guard = self.inner.data.lock();
let arc = Arc::clone(&guard.0);
((*arc).clone(), arc)
((**arc).clone(), arc)
};
let (new_local, res) = f(local).await?;
let mut guard = self.inner.write();
let mut guard = self.inner.data.lock();
if !Arc::ptr_eq(&guard.0, &original_arc) {
return Err(anyhow::anyhow!(
"Optimistic lock failed: Committed data has changed during async operation"
));
}
guard.0 = Arc::from(new_local);
guard.0 = Arc::new(Box::new(new_local));
Ok(res)
}
}
impl<T: Clone> Clone for Draft<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
#[inline]
fn try_acquire_data_modify_permit(&self) -> Option<DataModifyPermit<'_>> {
self.inner
.data_modifying
.compare_exchange(false, true, Acquire, Relaxed)
.ok()
.map(|_| DataModifyPermit(&self.inner.data_modifying, &self.inner.data_modify_notify))
}
#[inline]
async fn acquire_data_modify_permit(&self) -> DataModifyPermit<'_> {
for _ in 0..DATA_MODIFY_FAST_RETRY_YIELDS {
if let Some(permit) = self.try_acquire_data_modify_permit() {
return permit;
}
tokio::task::yield_now().await;
}
loop {
let notified = self.inner.data_modify_notify.notified();
if let Some(permit) = self.try_acquire_data_modify_permit() {
return permit;
}
notified.await;
}
}
}
/// A draft layer that can be claimed, committed or rolled back without knowing what it holds.
///
/// Exists so a change spanning several differently-typed layers can be treated as one unit.
pub trait DraftLayer: Send + Sync {
/// Take exclusive staging rights, or report that someone else holds them.
fn try_claim(&self) -> bool;
fn release(&self);
fn apply_draft(&self);
fn discard_draft(&self);
/// Names the layer in a [`DraftBusy`] error. Only ever read by humans.
fn layer_name(&self) -> &'static str;
}
impl<T: Clone + Send + Sync> DraftLayer for Draft<T> {
#[inline]
fn try_claim(&self) -> bool {
self.inner
.claimed
.compare_exchange(false, true, Acquire, Relaxed)
.is_ok()
}
#[inline]
fn release(&self) {
self.inner.claimed.store(false, Release);
}
#[inline]
fn apply_draft(&self) {
self.apply();
}
#[inline]
fn discard_draft(&self) {
self.discard();
}
#[inline]
fn layer_name(&self) -> &'static str {
std::any::type_name::<T>()
}
}
/// Another transaction is already staging a change to one of the layers asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftBusy {
/// The layer that was already claimed.
pub layer: &'static str,
}
impl std::fmt::Display for DraftBusy {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"configuration layer {} is being changed elsewhere",
self.layer
)
}
}
impl std::error::Error for DraftBusy {}
/// Commits a set of draft layers together, or rolls them all back.
///
/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a
/// change half-applied across layers. Committing is the explicit act, because forgetting to
/// commit costs a retry while forgetting to roll back leaves the app describing a state it is
/// not in.
///
/// Beginning one takes exclusive staging rights over every layer, and overlapping with another
/// transaction is refused rather than allowed to interleave. That is not caution, it is the
/// only honest option: a layer holds exactly one draft, so a second writer's `edit_draft`
/// overwrites the first writer's staged value *in place*. There is no version of "roll back
/// only my edit" that could be implemented — rolling back would discard a change the other
/// writer is about to commit, and it did: the other writer's `apply` then found no draft, did
/// nothing at all, and still reported success while its side effect had already happened.
#[must_use = "a transaction that is dropped without committing rolls back"]
pub struct DraftTransaction<'a> {
layers: Vec<&'a dyn DraftLayer>,
committed: bool,
}
impl<'a> DraftTransaction<'a> {
/// Claim `layers` and begin a transaction over them; they are committed in the order given.
///
/// Fails if any layer is already claimed, having released the ones taken so far, so a
/// refused attempt leaves nothing held.
pub fn begin(layers: Vec<&'a dyn DraftLayer>) -> Result<Self, DraftBusy> {
for (index, layer) in layers.iter().enumerate() {
if layer.try_claim() {
continue;
}
for claimed in &layers[..index] {
claimed.release();
}
return Err(DraftBusy {
layer: layer.layer_name(),
});
}
Ok(Self {
layers,
committed: false,
})
}
/// Commit every layer. Consumes the transaction so it cannot be committed twice.
pub fn commit(mut self) {
for layer in &self.layers {
layer.apply_draft();
}
self.committed = true;
}
/// Roll every layer back now, rather than whenever this value happens to drop.
///
/// Needed where recovery does more than forget the drafts — restoring files, or
/// regenerating them from the committed configuration. Those read the layers, so the
/// rollback has to have already happened, not be pending on a later drop.
pub fn rollback(self) {
drop(self);
}
}
impl Drop for DraftTransaction<'_> {
fn drop(&mut self) {
for layer in &self.layers {
if !self.committed {
layer.discard_draft();
}
layer.release();
}
}
}
+274 -90
View File
@@ -3,8 +3,10 @@ mod tests {
use anyhow::anyhow;
use clash_verge_draft::Draft;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use tokio::time::sleep;
#[derive(Clone, Debug, Default, PartialEq)]
struct IVerge {
@@ -12,69 +14,48 @@ mod tests {
enable_tun_mode: Option<bool>,
}
// Minimal single-threaded executor for immediately-ready futures
fn block_on_ready<F: Future>(fut: F) -> F::Output {
fn no_op_raw_waker() -> RawWaker {
fn clone(_: *const ()) -> RawWaker {
no_op_raw_waker()
}
fn wake(_: *const ()) {}
fn wake_by_ref(_: *const ()) {}
fn drop(_: *const ()) {}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop);
RawWaker::new(std::ptr::null(), &VTABLE)
}
const STRESS_LEVELS: [usize; 6] = [8, 16, 32, 64, 128, 256];
let waker = unsafe { Waker::from_raw(no_op_raw_waker()) };
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut);
loop {
match Pin::as_mut(&mut fut).poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => std::thread::yield_now(),
}
fn verge(enable_auto_launch: bool, enable_tun_mode: bool) -> IVerge {
IVerge {
enable_auto_launch: Some(enable_auto_launch),
enable_tun_mode: Some(enable_tun_mode),
}
}
fn block_on_ready<F: Future>(fut: F) -> F::Output {
tokio::runtime::Runtime::new().unwrap().block_on(fut)
}
#[test]
fn test_draft_basic_flow() {
let verge = IVerge {
enable_auto_launch: Some(true),
enable_tun_mode: Some(false),
};
let draft = Draft::new(verge);
let draft = Draft::new(verge(true, false));
// 读取正式数据(data_arc)
{
let data = draft.data_arc();
assert_eq!(data.enable_auto_launch, Some(true));
assert_eq!(data.enable_tun_mode, Some(false));
}
// 修改草稿(使用 edit_draft)
draft.edit_draft(|d| {
d.enable_auto_launch = Some(false);
d.enable_tun_mode = Some(true);
});
// 正式数据未变
{
let data = draft.data_arc();
assert_eq!(data.enable_auto_launch, Some(true));
assert_eq!(data.enable_tun_mode, Some(false));
}
// 草稿已变
{
let latest = draft.latest_arc();
assert_eq!(latest.enable_auto_launch, Some(false));
assert_eq!(latest.enable_tun_mode, Some(true));
}
// 提交草稿
draft.apply();
// 正式数据已更新
{
let data = draft.data_arc();
assert_eq!(data.enable_auto_launch, Some(false));
@@ -91,18 +72,13 @@ mod tests {
assert_eq!(latest.enable_tun_mode, Some(true));
}
// 丢弃草稿
draft.discard();
// 丢弃后再次创建草稿,会从已提交重新 clone
{
draft.edit_draft(|d| {
// 原 committed 是 enable_auto_launch = Some(false)
assert_eq!(d.enable_auto_launch, Some(false));
// 再修改一下
d.enable_tun_mode = Some(false);
});
// 草稿中值已修改,但正式数据仍是 apply 后的值
let data = draft.data_arc();
assert_eq!(data.enable_auto_launch, Some(false));
assert_eq!(data.enable_tun_mode, Some(true));
@@ -111,41 +87,29 @@ mod tests {
#[test]
fn test_arc_pointer_behavior_on_edit_and_apply() {
let draft = Draft::new(IVerge {
enable_auto_launch: Some(true),
enable_tun_mode: Some(false),
});
let draft = Draft::new(verge(true, false));
// 初始 latest == committed
let committed = draft.data_arc();
let latest = draft.latest_arc();
assert!(std::sync::Arc::ptr_eq(&committed, &latest));
assert!(Arc::ptr_eq(&committed, &latest));
// 第一次 edit:由于与 committed 共享,Arc::make_mut 会克隆
draft.edit_draft(|d| d.enable_tun_mode = Some(true));
let committed_after_first_edit = draft.data_arc();
let draft_after_first_edit = draft.latest_arc();
assert!(!std::sync::Arc::ptr_eq(
&committed_after_first_edit,
&draft_after_first_edit
));
// 提交会把 committed 指向草稿的 Arc
let prev_draft_ptr = std::sync::Arc::as_ptr(&draft_after_first_edit);
assert!(!Arc::ptr_eq(&committed_after_first_edit, &draft_after_first_edit));
let prev_draft_ptr = Arc::as_ptr(&draft_after_first_edit);
draft.apply();
let committed_after_apply = draft.data_arc();
assert_eq!(std::sync::Arc::as_ptr(&committed_after_apply), prev_draft_ptr);
assert_eq!(Arc::as_ptr(&committed_after_apply), prev_draft_ptr);
// 第二次编辑:此时草稿唯一持有(无其它引用),不应再克隆
// 获取草稿 Arc 的指针并立即丢弃本地引用,避免增加 strong_count
draft.edit_draft(|d| d.enable_auto_launch = Some(false));
let latest1 = draft.latest_arc();
let latest1_ptr = std::sync::Arc::as_ptr(&latest1);
drop(latest1); // 确保只有 Draft 内部持有草稿 Arc
let latest1_ptr = Arc::as_ptr(&latest1);
drop(latest1);
// 再次编辑(unique,Arc::make_mut 不应克隆)
draft.edit_draft(|d| d.enable_tun_mode = Some(false));
let latest2 = draft.latest_arc();
let latest2_ptr = std::sync::Arc::as_ptr(&latest2);
let latest2_ptr = Arc::as_ptr(&latest2);
assert_eq!(latest1_ptr, latest2_ptr, "Unique edit should not clone Arc");
assert_eq!(latest2.enable_auto_launch, Some(false));
@@ -154,22 +118,17 @@ mod tests {
#[test]
fn test_discard_restores_latest_to_committed() {
let draft = Draft::new(IVerge {
enable_auto_launch: Some(false),
enable_tun_mode: Some(false),
});
let draft = Draft::new(verge(false, false));
// 创建草稿并修改
draft.edit_draft(|d| d.enable_auto_launch = Some(true));
let committed = draft.data_arc();
let latest = draft.latest_arc();
assert!(!std::sync::Arc::ptr_eq(&committed, &latest));
assert!(!Arc::ptr_eq(&committed, &latest));
// 丢弃草稿后 latest 应回到 committed
draft.discard();
let committed2 = draft.data_arc();
let latest2 = draft.latest_arc();
assert!(std::sync::Arc::ptr_eq(&committed2, &latest2));
assert!(Arc::ptr_eq(&committed2, &latest2));
assert_eq!(latest2.enable_auto_launch, Some(false));
}
@@ -187,23 +146,13 @@ mod tests {
#[test]
fn test_with_data_modify_ok_and_replaces_committed() {
let draft = Draft::new(IVerge {
enable_auto_launch: Some(false),
enable_tun_mode: Some(false),
});
let draft = Draft::new(verge(false, false));
// 使用 with_data_modify 异步(立即就绪)地更新 committed
let res = block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(true);
Ok((v, "done"))
}));
assert_eq!(
{
#[allow(clippy::unwrap_used)]
res.unwrap()
},
"done"
);
assert_eq!(res.unwrap(), "done");
let committed = draft.data_arc();
assert_eq!(committed.enable_auto_launch, Some(true));
@@ -211,7 +160,20 @@ mod tests {
}
#[test]
fn test_with_data_modify_error_propagation() {
fn test_with_data_modify_keeps_boxed_data_shape() {
let draft = Draft::new(Box::new(verge(false, false)));
block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(true);
Ok((v, ()))
}))
.unwrap();
assert_eq!(draft.data_arc().enable_auto_launch, Some(true));
}
#[test]
fn test_with_data_modify_error_releases_permit() {
let draft = Draft::new(IVerge::default());
#[allow(clippy::unwrap_used)]
@@ -219,45 +181,267 @@ mod tests {
.unwrap_err();
assert_eq!(format!("{err}"), "boom");
#[allow(clippy::unwrap_used)]
block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(true);
Ok((v, ()))
}))
.unwrap();
assert_eq!(draft.data_arc().enable_auto_launch, Some(true));
}
#[test]
fn test_with_data_modify_serialized_under_stress_levels() {
for task_count in STRESS_LEVELS {
run_with_data_modify_stress(task_count, Duration::from_millis(1));
}
}
#[test]
fn test_with_data_modify_keeps_apply_conflict_detection() {
let rt = tokio::runtime::Runtime::new().unwrap();
let draft = Arc::new(Draft::new(verge(false, false)));
let started = Arc::new(Notify::new());
let finish = Arc::new(Notify::new());
rt.block_on(async {
let task = {
let draft = Arc::clone(&draft);
let started = Arc::clone(&started);
let finish = Arc::clone(&finish);
tokio::spawn(async move {
draft
.with_data_modify(|mut v| async move {
started.notify_one();
finish.notified().await;
v.enable_auto_launch = Some(true);
Ok((v, ()))
})
.await
})
};
started.notified().await;
draft.edit_draft(|d| d.enable_tun_mode = Some(true));
draft.apply();
finish.notify_one();
let err = task.await.expect("task panicked").unwrap_err();
assert_eq!(
format!("{err}"),
"Optimistic lock failed: Committed data has changed during async operation"
);
});
let committed = draft.data_arc();
assert_eq!(committed.enable_auto_launch, Some(false));
assert_eq!(committed.enable_tun_mode, Some(true));
}
fn run_with_data_modify_stress(task_count: usize, delay: Duration) {
let rt = tokio::runtime::Runtime::new().unwrap();
let draft = Arc::new(Draft::new(verge(false, false)));
rt.block_on(async {
let mut handles = Vec::with_capacity(task_count);
for _ in 0..task_count {
let draft = Arc::clone(&draft);
handles.push(tokio::spawn(async move {
draft
.with_data_modify(|mut v| async move {
if !delay.is_zero() {
sleep(delay).await;
}
v.enable_auto_launch = Some(!v.enable_auto_launch.unwrap_or(false));
Ok((v, ()))
})
.await
}));
}
for handle in handles {
handle.await.expect("task panicked").expect("with_data_modify failed");
}
Ok::<(), anyhow::Error>(())
})
.unwrap();
}
#[test]
fn test_with_data_modify_does_not_touch_existing_draft() {
let draft = Draft::new(IVerge {
enable_auto_launch: Some(false),
enable_tun_mode: Some(false),
});
let draft = Draft::new(verge(false, false));
// 创建草稿并修改
draft.edit_draft(|d| {
d.enable_auto_launch = Some(true);
d.enable_tun_mode = Some(true);
});
let draft_before = draft.latest_arc();
let draft_before_ptr = std::sync::Arc::as_ptr(&draft_before);
let draft_before_ptr = Arc::as_ptr(&draft_before);
// 同时通过 with_data_modify 修改 committed
#[allow(clippy::unwrap_used)]
block_on_ready(draft.with_data_modify(|mut v| async move {
v.enable_auto_launch = Some(false); // 与草稿不同
v.enable_auto_launch = Some(false);
Ok((v, ()))
}))
.unwrap();
// 草稿应保持不变
let draft_after = draft.latest_arc();
assert_eq!(
std::sync::Arc::as_ptr(&draft_after),
Arc::as_ptr(&draft_after),
draft_before_ptr,
"Existing draft should not be replaced by with_data_modify"
);
assert_eq!(draft_after.enable_auto_launch, Some(true));
assert_eq!(draft_after.enable_tun_mode, Some(true));
// 丢弃草稿后 latest == committed,且 committed 为异步修改结果
draft.discard();
let latest = draft.latest_arc();
assert_eq!(latest.enable_auto_launch, Some(false));
assert_eq!(latest.enable_tun_mode, Some(false));
}
}
mod transactions {
use clash_verge_draft::{Draft, DraftTransaction};
fn begin<'a>(layers: Vec<&'a dyn clash_verge_draft::DraftLayer>) -> DraftTransaction<'a> {
DraftTransaction::begin(layers).expect("no other transaction should hold these layers")
}
#[test]
fn committing_applies_every_layer() {
let first = Draft::new(1_u8);
let second = Draft::new("a".to_owned());
let transaction = begin(vec![&first, &second]);
first.edit_draft(|value| *value = 2);
second.edit_draft(|value| *value = "b".to_owned());
transaction.commit();
assert_eq!(**first.data_arc(), 2);
assert_eq!(**second.data_arc(), "b");
}
#[test]
fn dropping_without_committing_rolls_every_layer_back() {
let first = Draft::new(1_u8);
let second = Draft::new("a".to_owned());
{
let _transaction = begin(vec![&first, &second]);
first.edit_draft(|value| *value = 2);
second.edit_draft(|value| *value = "b".to_owned());
}
assert_eq!(**first.data_arc(), 1);
assert_eq!(**second.data_arc(), "a");
assert_eq!(
**first.latest_arc(),
1,
"the draft itself is gone, not just uncommitted"
);
}
#[test]
fn an_early_return_rolls_back_without_the_caller_saying_so() {
fn attempt(draft: &Draft<u8>, succeed: bool) -> Result<(), &'static str> {
let transaction = begin(vec![draft]);
draft.edit_draft(|value| *value = 9);
if !succeed {
return Err("failed after editing");
}
transaction.commit();
Ok(())
}
let draft = Draft::new(1_u8);
assert!(attempt(&draft, false).is_err());
assert_eq!(**draft.data_arc(), 1, "a failed attempt leaves nothing behind");
assert!(attempt(&draft, true).is_ok());
assert_eq!(**draft.data_arc(), 9);
}
#[test]
fn a_partly_edited_transaction_still_rolls_back_whole() {
// The layer nobody edited must not be left holding a stale draft either.
let edited = Draft::new(1_u8);
let untouched = Draft::new(5_u8);
drop(begin(vec![&edited, &untouched]));
edited.edit_draft(|value| *value = 2);
{
let _transaction = begin(vec![&edited, &untouched]);
}
assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too");
assert_eq!(**untouched.latest_arc(), 5);
}
}
mod transaction_exclusivity {
use clash_verge_draft::{Draft, DraftTransaction};
#[test]
fn a_second_transaction_over_a_claimed_layer_is_refused() {
let draft = Draft::new(1_u8);
let _held = DraftTransaction::begin(vec![&draft]).expect("the first claim should succeed");
let refused = DraftTransaction::begin(vec![&draft]);
assert!(refused.is_err(), "a layer may only be staged by one transaction");
}
#[test]
fn a_refused_transaction_leaves_the_layers_it_did_claim_free() {
// Claiming is all-or-nothing: a caller that failed on the third layer must not keep
// the first two, or the next attempt would be refused for a transaction that no
// longer exists.
let first = Draft::new(1_u8);
let second = Draft::new(2_u8);
let _contended = DraftTransaction::begin(vec![&second]).expect("the first claim should succeed");
assert!(DraftTransaction::begin(vec![&first, &second]).is_err());
drop(DraftTransaction::begin(vec![&first]).expect("the layer that was rolled off must be free"));
}
#[test]
fn a_layer_is_free_again_once_its_transaction_ends() {
let draft = Draft::new(1_u8);
drop(DraftTransaction::begin(vec![&draft]).expect("first"));
let committed = DraftTransaction::begin(vec![&draft]).expect("free after a rollback");
committed.commit();
drop(DraftTransaction::begin(vec![&draft]).expect("free after a commit"));
}
#[test]
fn an_overlapping_writer_can_no_longer_discard_a_staged_edit() {
// The regression this pins. A holds a transaction across a slow side effect; B starts,
// stages its own edit over the one draft slot, fails, and rolls back. Before layers
// were claimed, B's rollback cleared the single draft, A's commit then found nothing to
// apply and did nothing at all — while A's caller was told it had succeeded and A's own
// side effect had already happened.
let verge = Draft::new(String::from("committed"));
let transaction_a = DraftTransaction::begin(vec![&verge]).expect("A claims the layer");
verge.edit_draft(|value| *value = String::from("A-wants-this"));
let transaction_b = DraftTransaction::begin(vec![&verge]);
assert!(transaction_b.is_err(), "B must be refused rather than share the slot");
transaction_a.commit();
assert_eq!(
**verge.data_arc(),
"A-wants-this",
"A's edit must survive an overlapping writer"
);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
rust-i18n = "4.0.0"
rust-i18n = "4.2.1"
sys-locale = "0.3.2"
[lints]
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.
launchGuard:
currentExeError: 'تعذر تحديد موقع التطبيق: {error}'
homeDirError: 'تعذر تحديد المجلد الرئيسي للمستخدم الحالي. سيتم إنهاء التطبيق: {error}'
translocated: يشغّل macOS هذا التطبيق من مسار App Translocation مؤقت. انقله يدويًا في Finder إلى /Applications أو ~/Applications، ثم افتحه مجددًا.
rejected: |-
يجب تشغيل هذا التطبيق من مجلد Applications.
{reason}
notInBundle: الملف التنفيذي ليس داخل حزمة تطبيق.
canonicalizeBundleError: 'تعذر تحديد المسار الفعلي لحزمة التطبيق: {error}'
bundleNameError: تعذر تحديد اسم حزمة التطبيق. سيتم إنهاء التطبيق.
replacePrompt: يوجد تطبيق بالفعل في {destination}. هل تريد استبداله وإعادة تشغيله من Applications؟
movePrompt: لتشغيل خدمة الخلفية بأمان، يجب نقل التطبيق إلى {destination}. هل تريد نقله وإعادة تشغيله الآن؟
createApplicationsDirError: 'تعذر إنشاء مجلد Applications: {error}'
moveFailed: فشل نقل التطبيق. انقله يدويًا في Finder ثم أعد المحاولة.
replaceFailed: 'تعذر استبدال التطبيق الموجود بأمان: {error}'
relaunchFailed: تم نسخ التطبيق، ولكن تعذرت إعادة تشغيله تلقائيًا. افتحه يدويًا من Applications.
cancel: إلغاء
move: نقل
ok: موافق
tray:
dashboard: لوحة التحكم
ruleMode: وضع القواعد
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
launchGuard:
currentExeError: 'Der Speicherort der Anwendung konnte nicht ermittelt werden: {error}'
homeDirError: 'Das Benutzerverzeichnis des aktuellen Benutzers konnte nicht ermittelt werden. Die Anwendung wird beendet: {error}'
translocated: macOS führt diese Anwendung aus einem temporären App-Translocation-Pfad aus. Verschieben Sie sie im Finder manuell nach /Applications oder ~/Applications und öffnen Sie sie anschließend erneut.
rejected: |-
Diese Anwendung muss aus einem Applications-Ordner gestartet werden.
{reason}
notInBundle: Die ausführbare Datei befindet sich nicht in einem Anwendungspaket.
canonicalizeBundleError: 'Der Pfad des Anwendungspakets konnte nicht aufgelöst werden: {error}'
bundleNameError: Der Name des Anwendungspakets konnte nicht ermittelt werden. Die Anwendung wird beendet.
replacePrompt: Unter {destination} ist bereits eine Anwendung vorhanden. Soll sie ersetzt und aus Applications neu gestartet werden?
movePrompt: Damit der Hintergrunddienst sicher gestartet werden kann, muss die Anwendung nach {destination} verschoben werden. Jetzt verschieben und neu starten?
createApplicationsDirError: 'Der Applications-Ordner konnte nicht erstellt werden: {error}'
moveFailed: Die Anwendung konnte nicht verschoben werden. Verschieben Sie sie im Finder manuell und versuchen Sie es erneut.
replaceFailed: 'Die vorhandene Anwendung konnte nicht sicher ersetzt werden: {error}'
relaunchFailed: Die Anwendung wurde kopiert, konnte aber nicht automatisch neu gestartet werden. Öffnen Sie sie manuell aus Applications.
cancel: Abbrechen
move: Verschieben
ok: OK
tray:
dashboard: Übersicht
ruleMode: Regelmodus
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.
launchGuard:
currentExeError: 'Unable to determine the application location: {error}'
homeDirError: 'Unable to determine the current user''s home directory. The application will exit: {error}'
translocated: macOS is running this application from a temporary App Translocation path. In Finder, move it manually to /Applications or ~/Applications, then reopen it.
rejected: |-
This application must be launched from an Applications directory.
{reason}
notInBundle: The executable is not inside an application bundle.
canonicalizeBundleError: 'Unable to resolve the application bundle path: {error}'
bundleNameError: Unable to determine the application bundle name. The application will exit.
replacePrompt: An application already exists at {destination}. Replace it and relaunch from Applications?
movePrompt: To start the background service safely, the application must be moved to {destination}. Move and relaunch it now?
createApplicationsDirError: 'Unable to create the Applications directory: {error}'
moveFailed: Failed to move the application. Move it manually in Finder and try again.
replaceFailed: 'Unable to replace the existing application safely: {error}'
relaunchFailed: The application was copied, but it could not be relaunched automatically. Open it manually from Applications.
cancel: Cancel
move: Move
ok: OK
tray:
dashboard: Dashboard
ruleMode: Rule Mode
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.
launchGuard:
currentExeError: 'No se pudo determinar la ubicación de la aplicación: {error}'
homeDirError: 'No se pudo determinar la carpeta de inicio del usuario actual. La aplicación se cerrará: {error}'
translocated: macOS está ejecutando esta aplicación desde una ruta temporal de App Translocation. Muévela manualmente en Finder a /Applications o ~/Applications y vuelve a abrirla.
rejected: |-
Esta aplicación debe iniciarse desde una carpeta Applications.
{reason}
notInBundle: El archivo ejecutable no está dentro de un paquete de aplicación.
canonicalizeBundleError: 'No se pudo resolver la ruta del paquete de la aplicación: {error}'
bundleNameError: No se pudo determinar el nombre del paquete de la aplicación. La aplicación se cerrará.
replacePrompt: Ya existe una aplicación en {destination}. ¿Quieres reemplazarla y reiniciarla desde Applications?
movePrompt: Para iniciar el servicio en segundo plano de forma segura, la aplicación debe moverse a {destination}. ¿Quieres moverla y reiniciarla ahora?
createApplicationsDirError: 'No se pudo crear la carpeta Applications: {error}'
moveFailed: No se pudo mover la aplicación. Muévela manualmente en Finder e inténtalo de nuevo.
replaceFailed: 'No se pudo reemplazar de forma segura la aplicación existente: {error}'
relaunchFailed: La aplicación se copió, pero no se pudo reiniciar automáticamente. Ábrela manualmente desde Applications.
cancel: Cancelar
move: Mover
ok: Aceptar
tray:
dashboard: Panel
ruleMode: Modo de reglas
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.
launchGuard:
currentExeError: 'مکان برنامه مشخص نشد: {error}'
homeDirError: 'پوشهٔ خانگی کاربر فعلی مشخص نشد. برنامه بسته خواهد شد: {error}'
translocated: macOS این برنامه را از یک مسیر موقت App Translocation اجرا می‌کند. آن را در Finder به‌صورت دستی به /Applications یا ~/Applications منتقل کنید و دوباره باز کنید.
rejected: |-
این برنامه باید از پوشهٔ Applications اجرا شود.
{reason}
notInBundle: فایل اجرایی داخل بستهٔ برنامه نیست.
canonicalizeBundleError: 'مسیر بستهٔ برنامه قابل تشخیص نیست: {error}'
bundleNameError: نام بستهٔ برنامه مشخص نشد. برنامه بسته خواهد شد.
replacePrompt: در {destination} از قبل برنامه‌ای وجود دارد. جایگزین و از Applications دوباره اجرا شود؟
movePrompt: برای اجرای امن سرویس پس‌زمینه، برنامه باید به {destination} منتقل شود. اکنون منتقل و دوباره اجرا شود؟
createApplicationsDirError: 'پوشهٔ Applications ایجاد نشد: {error}'
moveFailed: انتقال برنامه ناموفق بود. آن را در Finder به‌صورت دستی منتقل کنید و دوباره تلاش کنید.
replaceFailed: 'برنامهٔ موجود به‌صورت امن جایگزین نشد: {error}'
relaunchFailed: برنامه کپی شد، اما اجرای دوبارهٔ خودکار ناموفق بود. آن را به‌صورت دستی از Applications باز کنید.
cancel: لغو
move: انتقال
ok: تأیید
tray:
dashboard: داشبورد
ruleMode: حالت قوانین
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.
launchGuard:
currentExeError: 'Lokasi aplikasi tidak dapat ditentukan: {error}'
homeDirError: 'Direktori beranda pengguna saat ini tidak dapat ditentukan. Aplikasi akan ditutup: {error}'
translocated: macOS menjalankan aplikasi ini dari jalur App Translocation sementara. Pindahkan secara manual di Finder ke /Applications atau ~/Applications, lalu buka kembali.
rejected: |-
Aplikasi ini harus dijalankan dari direktori Applications.
{reason}
notInBundle: Berkas yang dapat dijalankan tidak berada di dalam bundel aplikasi.
canonicalizeBundleError: 'Jalur bundel aplikasi tidak dapat diuraikan: {error}'
bundleNameError: Nama bundel aplikasi tidak dapat ditentukan. Aplikasi akan ditutup.
replacePrompt: Sudah ada aplikasi di {destination}. Ganti dan jalankan ulang dari Applications?
movePrompt: Agar layanan latar belakang dapat dimulai dengan aman, aplikasi harus dipindahkan ke {destination}. Pindahkan dan jalankan ulang sekarang?
createApplicationsDirError: 'Direktori Applications tidak dapat dibuat: {error}'
moveFailed: Aplikasi gagal dipindahkan. Pindahkan secara manual di Finder lalu coba lagi.
replaceFailed: 'Aplikasi yang ada tidak dapat diganti dengan aman: {error}'
relaunchFailed: Aplikasi telah disalin, tetapi tidak dapat dijalankan ulang secara otomatis. Buka secara manual dari Applications.
cancel: Batal
move: Pindahkan
ok: OK
tray:
dashboard: Dasbor
ruleMode: Mode Aturan
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。
launchGuard:
currentExeError: アプリケーションの場所を確認できませんでした:{error}
homeDirError: 現在のユーザーのホームディレクトリを確認できないため、アプリケーションを終了します:{error}
translocated: macOS が一時的な App Translocation パスからこのアプリケーションを実行しています。Finder で /Applications または ~/Applications に手動で移動してから、もう一度開いてください。
rejected: |-
このアプリケーションは Applications ディレクトリから起動する必要があります。
{reason}
notInBundle: 実行ファイルがアプリケーションバンドル内にありません。
canonicalizeBundleError: アプリケーションバンドルのパスを解決できませんでした:{error}
bundleNameError: アプリケーションバンドル名を確認できないため、アプリケーションを終了します。
replacePrompt: '{destination} には既にアプリケーションがあります。置き換えて Applications から再起動しますか?'
movePrompt: バックグラウンドサービスを安全に起動するには、アプリケーションを {destination} に移動する必要があります。今すぐ移動して再起動しますか?
createApplicationsDirError: Applications ディレクトリを作成できませんでした:{error}
moveFailed: アプリケーションを移動できませんでした。Finder で手動で移動してから、もう一度お試しください。
replaceFailed: 既存のアプリケーションを安全に置き換えられませんでした:{error}
relaunchFailed: アプリケーションはコピーされましたが、自動的に再起動できませんでした。Applications から手動で開いてください。
cancel: キャンセル
move: 移動
ok: OK
tray:
dashboard: ダッシュボード
ruleMode: ルールモード
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.
launchGuard:
currentExeError: '애플리케이션 위치를 확인할 수 없습니다: {error}'
homeDirError: '현재 사용자의 홈 디렉터리를 확인할 수 없어 애플리케이션을 종료합니다: {error}'
translocated: macOS가 임시 App Translocation 경로에서 이 애플리케이션을 실행하고 있습니다. Finder에서 /Applications 또는 ~/Applications로 직접 이동한 후 다시 여십시오.
rejected: |-
이 애플리케이션은 Applications 디렉터리에서 실행해야 합니다.
{reason}
notInBundle: 실행 파일이 애플리케이션 번들 안에 없습니다.
canonicalizeBundleError: '애플리케이션 번들 경로를 확인할 수 없습니다: {error}'
bundleNameError: 애플리케이션 번들 이름을 확인할 수 없어 애플리케이션을 종료합니다.
replacePrompt: '{destination}에 애플리케이션이 이미 있습니다. 교체하고 Applications에서 다시 실행하시겠습니까?'
movePrompt: 백그라운드 서비스를 안전하게 시작하려면 애플리케이션을 {destination}(으)로 이동해야 합니다. 지금 이동하고 다시 실행하시겠습니까?
createApplicationsDirError: 'Applications 디렉터리를 만들 수 없습니다: {error}'
moveFailed: 애플리케이션을 이동하지 못했습니다. Finder에서 직접 이동한 후 다시 시도하십시오.
replaceFailed: '기존 애플리케이션을 안전하게 교체할 수 없습니다: {error}'
relaunchFailed: 애플리케이션을 복사했지만 자동으로 다시 실행하지 못했습니다. Applications에서 직접 여십시오.
cancel: 취소
move: 이동
ok: 확인
tray:
dashboard: 대시보드
ruleMode: 규칙 모드
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.
launchGuard:
currentExeError: 'Не удалось определить расположение приложения: {error}'
homeDirError: 'Не удалось определить домашний каталог текущего пользователя. Приложение будет закрыто: {error}'
translocated: macOS запускает это приложение из временного пути App Translocation. Переместите его вручную в Finder в /Applications или ~/Applications, затем откройте снова.
rejected: |-
Это приложение необходимо запускать из каталога Applications.
{reason}
notInBundle: Исполняемый файл не находится внутри пакета приложения.
canonicalizeBundleError: 'Не удалось определить путь к пакету приложения: {error}'
bundleNameError: Не удалось определить имя пакета приложения. Приложение будет закрыто.
replacePrompt: В {destination} уже есть приложение. Заменить его и перезапустить из Applications?
movePrompt: Для безопасного запуска фоновой службы приложение необходимо переместить в {destination}. Переместить и перезапустить сейчас?
createApplicationsDirError: 'Не удалось создать каталог Applications: {error}'
moveFailed: Не удалось переместить приложение. Переместите его вручную в Finder и повторите попытку.
replaceFailed: 'Не удалось безопасно заменить существующее приложение: {error}'
relaunchFailed: Приложение скопировано, но его не удалось перезапустить автоматически. Откройте его вручную из Applications.
cancel: Отмена
move: Переместить
ok: ОК
tray:
dashboard: Панель
ruleMode: Режим правил
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
launchGuard:
currentExeError: 'Uygulamanın konumu belirlenemedi: {error}'
homeDirError: 'Geçerli kullanıcının ana dizini belirlenemedi. Uygulama kapatılacak: {error}'
translocated: macOS bu uygulamayı geçici bir App Translocation yolundan çalıştırıyor. Finder'da uygulamayı elle /Applications veya ~/Applications dizinine taşıyın ve yeniden açın.
rejected: |-
Bu uygulama bir Applications dizininden başlatılmalıdır.
{reason}
notInBundle: Yürütülebilir dosya bir uygulama paketinin içinde değil.
canonicalizeBundleError: 'Uygulama paketinin yolu çözümlenemedi: {error}'
bundleNameError: Uygulama paketinin adı belirlenemedi. Uygulama kapatılacak.
replacePrompt: '{destination} konumunda zaten bir uygulama var. Değiştirilsin ve Applications üzerinden yeniden başlatılsın mı?'
movePrompt: Arka plan hizmetini güvenli şekilde başlatmak için uygulamanın {destination} konumuna taşınması gerekir. Şimdi taşınıp yeniden başlatılsın mı?
createApplicationsDirError: 'Applications dizini oluşturulamadı: {error}'
moveFailed: Uygulama taşınamadı. Finder'da elle taşıyıp tekrar deneyin.
replaceFailed: 'Mevcut uygulama güvenli şekilde değiştirilemedi: {error}'
relaunchFailed: Uygulama kopyalandı ancak otomatik olarak yeniden başlatılamadı. Applications üzerinden elle açın.
cancel: İptal
move: Taşı
ok: Tamam
tray:
dashboard: Gösterge Paneli
ruleMode: Kural Modu
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.
launchGuard:
currentExeError: 'Кушымтаның урнашу урынын билгеләп булмады: {error}'
homeDirError: 'Хәзерге кулланучының өй каталогын билгеләп булмады. Кушымта ябылачак: {error}'
translocated: macOS бу кушымтаны вакытлы App Translocation юлыннан эшләтә. Finder аша аны /Applications яки ~/Applications каталогына кулдан күчерегез һәм яңадан ачыгыз.
rejected: |-
Бу кушымта Applications каталогыннан эшләтелергә тиеш.
{reason}
notInBundle: Башкарылучы файл кушымта пакетында түгел.
canonicalizeBundleError: 'Кушымта пакетының юлын билгеләп булмады: {error}'
bundleNameError: Кушымта пакетының исемен билгеләп булмады. Кушымта ябылачак.
replacePrompt: '{destination} урынында кушымта бар инде. Аны алыштырып, Applications аша яңадан эшләтергәме?'
movePrompt: Фон хезмәтен имин эшләтү өчен кушымтаны {destination} урынына күчерергә кирәк. Хәзер күчереп, яңадан эшләтергәме?
createApplicationsDirError: 'Applications каталогын төзеп булмады: {error}'
moveFailed: Кушымтаны күчереп булмады. Аны Finder аша кулдан күчереп, кабатлап карагыз.
replaceFailed: 'Булган кушымтаны имин алыштырып булмады: {error}'
relaunchFailed: Кушымта күчерелде, ләкин автоматик рәвештә яңадан эшләтеп булмады. Аны Applications аша кулдан ачыгыз.
cancel: Баш тарту
move: Күчерү
ok: Ярый
tray:
dashboard: Идарә панеле
ruleMode: Кагыйдә режимы
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限
launchGuard:
currentExeError: 无法确定应用位置:{error}
homeDirError: 无法确定当前用户主目录,应用将退出:{error}
translocated: macOS 正在从 App Translocation 临时路径运行此应用。请在 Finder 中将应用手动拖到 /Applications 或 ~/Applications 后重新打开。
rejected: |-
此应用必须从 Applications 目录启动。
{reason}
notInBundle: 可执行文件不在应用包内。
canonicalizeBundleError: 无法解析应用包路径:{error}
bundleNameError: 无法确定应用包名称,应用将退出。
replacePrompt: 目标位置已存在应用:{destination}。是否替换并从 Applications 重新启动?
movePrompt: 为了安全启动后台服务,需要将应用移动到 {destination}。是否现在移动并重新启动?
createApplicationsDirError: 无法创建 Applications 目录:{error}
moveFailed: 移动应用失败。请在 Finder 中手动移动后重试。
replaceFailed: 无法安全替换现有应用:{error}
relaunchFailed: 应用已复制,但自动重新启动失败。请从 Applications 手动打开。
cancel: 取消
move: 移动
ok: 好
tray:
dashboard: 仪表板
ruleMode: 规则模式
+20
View File
@@ -34,6 +34,26 @@ notifications:
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限
launchGuard:
currentExeError: 無法判斷應用程式位置:{error}
homeDirError: 無法判斷目前使用者的個人目錄,應用程式將結束:{error}
translocated: macOS 正從 App Translocation 暫存路徑執行此應用程式。請在 Finder 中將應用程式手動移至 /Applications 或 ~/Applications,然後重新開啟。
rejected: |-
此應用程式必須從 Applications 目錄啟動。
{reason}
notInBundle: 可執行檔不在應用程式套件內。
canonicalizeBundleError: 無法解析應用程式套件路徑:{error}
bundleNameError: 無法判斷應用程式套件名稱,應用程式將結束。
replacePrompt: 目標位置已有應用程式:{destination}。是否取代並從 Applications 重新啟動?
movePrompt: 為了安全啟動背景服務,需要將應用程式移至 {destination}。是否立即移動並重新啟動?
createApplicationsDirError: 無法建立 Applications 目錄:{error}
moveFailed: 移動應用程式失敗。請在 Finder 中手動移動後再試一次。
replaceFailed: 無法安全取代現有應用程式:{error}
relaunchFailed: 應用程式已複製,但無法自動重新啟動。請從 Applications 手動開啟。
cancel: 取消
move: 移動
ok: 好
tray:
dashboard: 儀表板
ruleMode: 規則模式
+1 -1
View File
@@ -39,7 +39,7 @@ fn resolve_supported_language(language: &str) -> Option<Cow<'static, str>> {
}
#[inline]
fn current_language(language: Option<&str>) -> Cow<'static, str> {
pub fn current_language(language: Option<&str>) -> Cow<'static, str> {
language
.filter(|lang| !lang.is_empty())
.and_then(resolve_supported_language)
+22 -10
View File
@@ -97,23 +97,35 @@ pub fn write_sidecar_log(
let _ = writer.write(now, &record);
}
pub struct NoModuleFilter<'a>(pub Vec<&'a str>);
pub struct ModuleFilter<'a> {
block: Vec<&'a str>,
exclude: Option<Vec<&'a str>>,
}
impl<'a> ModuleFilter<'a> {
pub fn new(block: Vec<&'a str>, exclude: Option<Vec<&'a str>>) -> Self {
Self { block, exclude }
}
impl<'a> NoModuleFilter<'a> {
#[inline]
pub fn filter(&self, record: &Record) -> bool {
if let Some(module) = record.module_path() {
for blocked in self.0.iter() {
if module.len() >= blocked.len() && module.as_bytes()[..blocked.len()] == blocked.as_bytes()[..] {
return false;
}
}
let Some(module) = record.module_path() else {
return true;
};
// 优先检查排除(白名单)
if let Some(excludes) = &self.exclude
&& excludes.iter().any(|e| module.starts_with(e))
{
return true;
}
true
// 再检查阻止(黑名单)
!self.block.iter().any(|b| module.starts_with(b))
}
}
impl<'a> LogLineFilter for NoModuleFilter<'a> {
impl<'a> LogLineFilter for ModuleFilter<'a> {
#[inline]
fn write(
&self,
+1 -1
View File
@@ -7,7 +7,7 @@ rust-version = "1.91"
[dependencies]
clash-verge-logging = { workspace = true }
log = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["signal"] }
[lints]
workspace = true
+75 -16
View File
@@ -1,4 +1,7 @@
use std::sync::OnceLock;
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
use clash_verge_logging::{Type, logging};
@@ -8,26 +11,55 @@ mod unix;
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
impl ShutdownLatch {
const fn new() -> Self {
Self {
is_cleaning_up: AtomicBool::new(false),
}
}
fn try_begin(&self) -> bool {
self.is_cleaning_up
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
}
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
RUNTIME.get_or_init(
|| match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(rt) => Some(rt),
Err(e) => {
logging!(
info,
Type::SystemSignal,
"register shutdown signal failed, create tokio runtime error: {}",
e
);
None
}
},
);
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
Err(e) => {
logging!(
info,
Type::SystemSignal,
"register shutdown signal failed, create tokio runtime error: {}",
e
);
None
}
});
#[cfg(unix)]
unix::register(f);
@@ -35,3 +67,30 @@ where
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::{ShutdownLatch, ShutdownOutcome};
#[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+4 -10
View File
@@ -1,16 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::RUNTIME;
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -53,7 +49,7 @@ where
}
}
if IS_CLEANING_UP.load(Ordering::SeqCst) {
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
@@ -62,11 +58,9 @@ where
);
continue;
}
IS_CLEANING_UP.store(true, Ordering::SeqCst);
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
+4 -10
View File
@@ -1,16 +1,12 @@
use std::sync::atomic::{AtomicBool, Ordering};
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::RUNTIME;
static IS_CLEANING_UP: AtomicBool = AtomicBool::new(false);
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future + Send + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -63,7 +59,7 @@ where
}
}
if IS_CLEANING_UP.load(Ordering::SeqCst) {
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
@@ -72,11 +68,9 @@ where
);
continue;
}
IS_CLEANING_UP.store(true, Ordering::SeqCst);
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
f().await;
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
@@ -8,12 +8,13 @@ rust-version = "1.91"
tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true, features = ["derive"] }
# sysinfo 0.38.2 conflicts with dark-light
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
sysinfo = { version = "0.38", features = ["network", "system"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.183"
libc = "0.2.189"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
@@ -1,13 +1,39 @@
use parking_lot::RwLock;
use serde::Serialize;
use tauri::{AppHandle, Runtime, State, command};
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
use crate::Platform;
// TODO 迁移,让新的结构体允许通过 tauri command 正确使用 structure.field 方式获取信息
#[derive(Serialize)]
pub struct SystemInfo {
pub system_name: String,
pub system_version: String,
pub system_kernel_version: String,
pub system_arch: String,
pub app_version: String,
pub app_core_mode: String,
pub app_is_admin: bool,
}
impl From<Platform> for SystemInfo {
fn from(platform: Platform) -> Self {
Self {
system_name: platform.sysinfo.system_name,
system_version: platform.sysinfo.system_version,
system_kernel_version: platform.sysinfo.system_kernel_version,
system_arch: platform.sysinfo.system_arch,
app_version: platform.appinfo.app_version,
app_core_mode: platform.appinfo.app_core_mode,
app_is_admin: platform.appinfo.app_is_admin,
}
}
}
#[command]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<String, Error> {
Ok(state.inner().read().to_string())
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
let platform = state.inner().read();
Ok(SystemInfo::from(platform.clone()))
}
/// 获取应用的运行时间(毫秒)
@@ -16,6 +16,7 @@ use tauri::{
plugin::{Builder, TauriPlugin},
};
#[derive(Clone)]
pub struct SysInfo {
system_name: String,
system_version: String,
@@ -39,6 +40,7 @@ impl Default for SysInfo {
}
}
#[derive(Clone)]
pub struct AppInfo {
app_version: String,
app_core_mode: String,
@@ -62,7 +64,7 @@ impl Default for AppInfo {
}
}
#[derive(Default)]
#[derive(Default, Clone)]
pub struct Platform {
pub sysinfo: SysInfo,
pub appinfo: AppInfo,
+4 -7
View File
@@ -21,15 +21,13 @@ src/locales/
home.json
shared.json
...
index.ts
zh/
...
```
- JSON files map to namespaces (for example `home.json` → `home.*`). Keep keys scoped to the file they belong to.
- `shared.json` stores reusable vocabulary (buttons, validations, etc.); feature-specific wording should live in the relevant namespace.
- `index.ts` re-exports a `resources` object that aggregates the namespace JSON files. When adding or removing namespaces, mirror the pattern from `src/locales/en/index.ts`.
- Frontend bundles are lazy-loaded by `src/services/i18n.ts`. Only languages listed in `supportedLanguages` are fetched at runtime, so append new codes there when you add a locale.
- `src/services/i18n.ts` automatically discovers and lazy-loads each locale's JSON sections, so no aggregate module is required. Keep locales aligned with `src/locales/en/`, and add new codes to `supportedLanguages`.
Because backend translations now live in their own directory, you no longer need to run `pnpm prebuild` just to sync locales—the frontend folder is the sole source of truth for web bundles.
@@ -52,10 +50,9 @@ Native UI strings (tray menu, notifications, dialogs) use `rust-i18n` with YAML
## Adding a new language
1. Duplicate `src/locales/en/` into `src/locales/<new-lang>/` and translate the JSON files while preserving key structure.
2. Update the locale’s `index.ts` to import every namespace. Matching the English file is the easiest way to avoid missing exports.
3. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
4. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
5. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
2. Append the language code to `supportedLanguages` in `src/services/i18n.ts`.
3. If the backend should expose the language, create `crates/clash-verge-i18n/<new-lang>.yml` and translate the keys used in existing YAML files.
4. Run `pnpm i18n:format`, `pnpm i18n:types`, and (optionally) `pnpm i18n:check` in dry-run mode to confirm structure.
## Authoring guidelines
+132
View File
@@ -1,3 +1,135 @@
## v2.5.2
### 🐞 修复问题
- 修复 macOS 托盘速率显示样式异常
- 修复订阅 TLS 1.0/1.1 等过旧协议时显示更明确错误原因
- 修复 gzip 压缩订阅响应被当作无效 YAML 导致导入失败的问题
- 修复订阅 URL 使用空密码 Basic Auth 时未发送认证信息的问题
- 修复 Linux 托盘可能与其他 Tauri 程序冲突导致的图标异常
- 修复前端连接页面导致的内存泄漏
- 修复 macOS 12(Monterey)首页 IP 卡片兼容性问题
- 修复首页代理卡片可能错误显示通信异常的问题
- 修复 Fake-IP 模式开启 IPv6 后未生成 fake-ip-range6 的问题
- 修复 DNS 覆写的高级模式无法正常编辑
- 修复部分非标准 WebDAV 服务器在备份目录已存在时的问题
- 修复 Linux 应用内更新问题
- 修复 JS 脚本验证因 console 方法调用导致的执行失败问题
- 修复 macOS 内存压力下 WebView 渲染进程被系统终止引发的白屏与主进程内存泄漏
- 修复全局热键开关设置可能被意外覆盖的问题
- 修复编辑节点时 Base64 解码未能正确处理非 ASCII 字符的问题
- 修复订阅规则编辑器自定义规则偶发显示为空的问题
- 修复开启“允许局域网连接”后,其他局域网设备仍可能无法连接代理的问题
- 修复“允许局域网连接”页面可能无法显示网络接口的问题
- 修复导入包含无效 v2ray-plugin 参数的 ss:// 链接时可能崩溃的问题
- 修复 macOS 托盘图标可能闪烁的问题
- 修复 macOS 应用启动及从菜单栏重新打开时,窗口可能无法正确激活的问题
- 修复 macOS App Translocation 状态下服务安装或启动异常的问题
- 修复异常窗口尺寸被保存后,应用窗口可能无法正常显示的问题
- 修复首页上传和下载总流量统计不准确的问题
- 修复首页当前代理状态偶尔不同步的问题
- 修复 URLTest 代理组未出现在部分代理组选择列表中的问题
- 修复切换代理模式失败时未正确显示错误的问题
- 修复重新加载 Clash 配置后规则页面数据未及时刷新的问题
- 修复全局快捷键隐藏主窗口行为异常的问题
- 修复外部控制器 CORS 编辑时输入框可能丢失焦点的问题
- 修复日志刷新时页面可能被强制滚动到底部的问题
- 修复全局扩展脚本中的通配符配置可能生成无效 YAML 的问题
- 修复代理组筛选后定位及滚动位置恢复异常的问题
- 修复编辑订阅配置时清空 HTTP 请求超时会报错的问题
- 修复纯 IP 连接的主机地址显示错误,并保留目标 IP 为空时的地址回退
- 修复 Windows 管理员模式下启动 TUN 时因等待不可用 Service 导致延迟 30 秒的问题
- 修复内核启动慢导致首页上下跳动的问题
- 修复 VLESS xhttp 链接解析及 IPv6 地址格式问题
- 修复通过普通导入或深链导入订阅后,自动更新任务未及时生效的问题
- 修复链式配置文件首次编辑失败及校验回滚后遗留空文件的问题,并优化文件读取错误提示
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- **Mihomo(Meta) 内核升级至 v1.19.29**
- 增加 TrustTunnel、OpenVPN、Tailscale、GostRelay 节点显示支持
- 全局扩展脚本增加恢复默认按钮
- DNS 添加 fake-ip-range6 可配置项
- Linux 无边框窗口支持拖拽调整大小
- 代理组新增筛选、排序、延迟测试和快速定位工具
- 首页当前节点过滤已隐藏的代理组
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 更健壮的服务生命周期管理
- 更健壮的 Mihomo API 通信机制
- 关闭 autofill 弹出窗口
- 改进切换订阅后激活选中节点的逻辑
- 实现代理组粘性滚动列表
- 完善了配置覆写的相关逻辑
- 支持 Provider 节点延迟检测
- 优化页面不可见时的 Mihomo WebSocket 订阅,减少后台资源占用
- 代理组支持分别恢复普通模式和链式代理模式的滚动位置
- 重构改进订阅配置切换逻辑
- 优化大量订阅卡片时的拖拽排序性能
</details>
<details>
<summary><strong> 👙 界面样式 </strong></summary>
- 改进 Sticky Groups 的吸顶阴影、间距和展开/折叠样式
- 优化代理组工具栏和筛选框布局
</details>
## v2.5.1
### 🐞 修复问题
- 备份设置功能异常
- 修复 Windows 节点交互异常
</details>
## v2.5.0
> [!IMPORTANT]
> 关于版本的说明:Clash Verge 版本号遵循 x.y.z:x 为重大架构变更,y 为功能新增,z 为 Bug 修复。
- **Mihomo(Meta) 内核升级至 v1.19.25**
### 🐞 修复问题
- 修复系统代理关闭后在 PAC 模式下未完全关闭
- 修复 macOS 开关代理时可能的卡死
- 修复修改定时自动更新后记时未及时刷新
- 修复 Linux 关闭 TUN 不立即生效
- 修复系统代理关闭序列逻辑(防止快速退出时系统代理关闭状态没有保存)
- 修复 Linux 快捷键映射错误
- 修复 Linux Wayland 兼容性导致渲染错误
- 修复 Linux 边缘情况无法加载页面
### ✨ 新增功能
- 订阅 QR code 分享
- 新增 macOS 托盘速率显示
- 快捷键操作通知操作结果
- 软件自动更新(后台下载,下次启动自动安装)
### 🚀 优化改进
- 优化 macOS 读取系统代理性能
- 优化前端 CPU 性能
- 更健壮的服务模式与边缘状况内核恢复
- 优化白名单网络下的订阅 TLS 更新兼容性
### 👙 界面样式
- 代理组实现 Sticky Scroll 效果
## v2.4.7
### 🐞 修复问题
+8 -1
View File
@@ -2,15 +2,18 @@ import eslintJS from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import pluginESx from 'eslint-plugin-es-x'
import pluginImportX from 'eslint-plugin-import-x'
import pluginReactCompiler from 'eslint-plugin-react-compiler'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import pluginReactRefresh from 'eslint-plugin-react-refresh'
import PluginSukka from 'eslint-plugin-sukka'
import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig([
pluginESx.configs['flat/restrict-to-es2022'],
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
@@ -19,9 +22,11 @@ export default defineConfig([
// @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543
'react-hooks': pluginReactHooks,
'react-compiler': pluginReactCompiler,
'es-x': pluginESx,
'import-x': pluginImportX,
'react-refresh': pluginReactRefresh,
'unused-imports': pluginUnusedImports,
sukka: PluginSukka,
},
extends: [
@@ -38,7 +43,9 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
],
},
},
@@ -88,7 +95,7 @@ export default defineConfig([
'@eslint-react/no-unused-class-component-members': 'error',
'@eslint-react/no-unused-state': 'error',
'@eslint-react/jsx-no-useless-fragment': 'warn',
'@eslint-react/prefer-destructuring-assignment': 'warn',
'sukka/react-prefer-destructuring-assignment': 'warn',
// TypeScript
'@typescript-eslint/no-explicit-any': 'off',
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!"],
"project": ["src/**/*.{js,jsx,ts,tsx}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
"dependencies",
"devDependencies",
"unlisted",
"unresolved",
"binaries",
"exports",
"types",
"duplicates",
"cycles"
],
"ignoreBinaries": ["rustc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
}
}
+70 -70
View File
@@ -1,12 +1,14 @@
{
"name": "clash-verge",
"version": "2.5.0",
"version": "2.5.3",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
"dev": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
"dev:diff": "cross-env RUST_BACKTRACE=full tauri dev -f verge-dev",
"dev:trace": "cross-env RUST_BACKTRACE=full RUSTFLAGS=\"--cfg tokio_unstable\" tauri dev -f verge-dev tokio-trace",
"dev": "node scripts/dev.mjs dev",
"dev:service": "node scripts/dev.mjs service",
"dev:sidecar": "node scripts/dev.mjs sidecar",
"dev:diff": "node scripts/dev.mjs dev",
"dev:trace": "node scripts/dev.mjs trace",
"dev:tauri": "cross-env RUST_BACKTRACE=full tauri dev -f tauri-dev",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build",
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
@@ -24,6 +26,7 @@
"release:autobuild": "pnpm release-version autobuild",
"release:deploytest": "pnpm release-version deploytest",
"publish-version": "node scripts/publish-version.mjs",
"eslint": "pnpm lint",
"lint": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache src",
"lint:fix": "eslint -c eslint.config.ts --max-warnings=0 --cache --cache-location .eslintcache --fix src",
"format": "biome format --write .",
@@ -31,7 +34,12 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:dev-control": "node --test scripts/dev-control.test.mjs scripts/service-release.test.mjs",
"knip:check": "knip",
"knip:report": "knip --no-exit-code"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -41,104 +49,96 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.0.0",
"@mui/lab": "9.0.0-beta.2",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "2.10.1",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.5.9",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-updater": "2.10.1",
"ahooks": "^3.9.6",
"ahooks": "^3.9.7",
"cidr-block": "^2.3.0",
"dayjs": "1.11.20",
"foxact": "^0.3.0",
"foxts": "^5.3.0",
"i18next": "^26.0.0",
"js-yaml": "^4.1.1",
"lodash-es": "^4.17.23",
"meta-json-schema": "^1.19.21",
"dayjs": "1.11.21",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.25",
"monaco-editor": "^0.55.1",
"monaco-yaml": "^5.4.1",
"nanoid": "^5.1.7",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-error-boundary": "6.1.1",
"react-hook-form": "^7.72.0",
"react-i18next": "17.0.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.2",
"react-hook-form": "^7.76.1",
"react-i18next": "17.0.11",
"react-markdown": "10.1.0",
"react-router": "^7.13.1",
"react-router": "^8.0.0",
"rehype-raw": "^7.0.0",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo",
"swr": "^2.4.2",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#refactor-use-reqwest-ipc",
"types-pac": "^1.0.3",
"validator": "^13.15.26"
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.0.0",
"@biomejs/biome": "^2.4.10",
"@eslint-react/eslint-plugin": "^4.0.0",
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.10.1",
"@types/js-yaml": "^4.0.9",
"@tauri-apps/cli": "2.11.4",
"@testing-library/react": "^16.3.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.0",
"@types/react": "19.2.14",
"@types/node": "^24.12.4",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.0",
"@vitejs/plugin-react": "^6.0.1",
"adm-zip": "^0.5.16",
"axios": "^1.13.6",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"cli-color": "^2.0.4",
"commander": "^14.0.3",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.1.0",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^9.5.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.4.0",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"lint-staged": "^16.4.0",
"node-fetch": "^3.3.2",
"sass": "^1.98.0",
"tar": "^7.5.12",
"terser": "^5.46.1",
"typescript": "^6.0.0",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1",
"vite-plugin-svgr": "^5.0.0"
"jiti": "^2.7.0",
"jsdom": "^30.0.0",
"knip": "6.29.0",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.10"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
"eslint --fix --max-warnings=0",
"biome format --write"
],
"*.{css,scss,json,yaml,yml}": [
"*.{css,scss,json}": [
"biome format --write"
]
},
"type": "module",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"core-js",
"es5-ext",
"meta-json-schema",
"unrs-resolver"
]
}
"packageManager": "pnpm@11.3.0"
}
+3335 -2346
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
allowBuilds:
"@parcel/watcher": true
core-js: true
es5-ext: true
meta-json-schema: true
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- jsdom@30.0.0
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.91.0"
channel = "1.95.0"
components = ["rustfmt", "clippy"]
+2 -62
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -1020,7 +1018,7 @@ function loadBackendLocales() {
const localePath = path.join(BACKEND_LOCALES_DIR, entry.name)
const name = entry.name.replace(/\.(ya?ml)$/i, '')
const raw = fs.readFileSync(localePath, 'utf8')
let data = {}
let data
try {
const parsed = yaml.load(raw)
data = isPlainObject(parsed) ? parsed : {}
@@ -1055,6 +1053,7 @@ function ensureBackup(localePath) {
} catch (error) {
throw new Error(
`Failed to recycle existing backup for ${path.basename(localePath)}: ${error.message}`,
{ cause: error },
)
}
}
@@ -1086,59 +1085,6 @@ function cleanupBackups(backups) {
backups.clear()
}
function toModuleIdentifier(namespace, seen) {
const RESERVED = new Set([
'default',
'function',
'var',
'let',
'const',
'import',
'export',
'class',
'enum',
])
const base =
namespace.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, '') ||
'ns'
let candidate = base
let counter = 1
while (RESERVED.has(candidate) || seen.has(candidate)) {
candidate = `${base}_${counter}`
counter += 1
}
seen.add(candidate)
return candidate
}
function regenerateLocaleIndex(localeDir, namespaces) {
const seen = new Set()
const imports = []
const mappings = []
for (const namespace of namespaces) {
const filePath = path.join(localeDir, `${namespace}.json`)
if (!fs.existsSync(filePath)) continue
const identifier = toModuleIdentifier(namespace, seen)
const key = namespace === identifier ? namespace : `'${namespace}'`
imports.push(`import ${identifier} from './${namespace}.json'`)
mappings.push(` ${key}: ${identifier},`)
}
const content = `${imports.join('\n')}
const resources = {
${mappings.join('\n')}
}
export default resources
`
fs.writeFileSync(path.join(localeDir, 'index.ts'), content, 'utf8')
}
function writeLocale(locale, data, options) {
const backups = new Map()
let success = false
@@ -1163,7 +1109,6 @@ function writeLocale(locale, data, options) {
}
const entries = Object.entries(data)
const orderedNamespaces = entries.map(([namespace]) => namespace)
const existingFiles = new Map(
locale.files.map((file) => [file.namespace, file.path]),
)
@@ -1187,11 +1132,6 @@ function writeLocale(locale, data, options) {
}
}
regenerateLocaleIndex(locale.dir, orderedNamespaces)
locale.files = orderedNamespaces.map((namespace) => ({
namespace,
path: path.join(locale.dir, `${namespace}.json`),
}))
success = true
} finally {
if (success) {
+280
View File
@@ -0,0 +1,280 @@
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import { request } from 'node:http'
import { connect } from 'node:net'
const DEFAULT_TIMEOUT_MS = 10_000
const DEFAULT_RETRY_DELAY_MS = 100
const TRANSIENT_REQUEST_ERRORS = new Set([
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
])
const SAFE_REQUEST_ERROR_CODES = new Set([
...TRANSIENT_REQUEST_ERRORS,
'EACCES',
'EINVAL',
'ENOTFOUND',
])
function validateInstanceRecord(record) {
if (
!record ||
!Number.isInteger(record.port) ||
record.port < 1 ||
record.port > 65_535
) {
throw new Error('invalid instance record port')
}
if (
typeof record.token !== 'string' ||
!/^[0-9a-f]{64}$/.test(record.token)
) {
throw new Error('invalid instance record token')
}
return { port: record.port, token: record.token }
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function safeRequestError(error) {
const code = SAFE_REQUEST_ERROR_CODES.has(error?.code)
? error.code
: 'UNKNOWN'
const cause = new Error(code)
cause.code = code
return new Error('dev quit request failed', { cause })
}
export async function readPrivateInstanceRecord(path, expectedUid) {
let handle
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
const stat = await handle.stat()
if (!stat.isFile()) {
throw new Error('instance record must be an ordinary file')
}
if (stat.uid !== expectedUid) {
throw new Error('instance record has the wrong owner')
}
if ((stat.mode & 0o777) !== 0o600) {
throw new Error('instance record permissions must be 0600')
}
const contents = await handle.readFile({ encoding: 'utf8' })
let record
try {
record = JSON.parse(contents)
} catch {
throw new Error('instance record is not valid JSON')
}
return validateInstanceRecord(record)
} finally {
await handle?.close()
}
}
function postDevQuit(record, timeoutMs, requestImpl) {
return new Promise((resolve, reject) => {
let settled = false
const settle = (callback, value) => {
if (settled) return
settled = true
callback(value)
}
const outgoing = requestImpl(
{
host: '127.0.0.1',
port: record.port,
path: '/commands/dev/quit',
method: 'POST',
agent: false,
headers: {
'X-Instance-Token': record.token,
},
},
(response) => {
response.on('aborted', () => {})
response.on('error', () => {})
response.on('close', () => {})
settle(resolve, response.statusCode)
response.destroy()
},
)
outgoing.once('error', (error) => settle(reject, error))
outgoing.setTimeout(timeoutMs, () => {
const error = new Error('dev quit request timed out')
error.code = 'ETIMEDOUT'
outgoing.destroy(error)
})
outgoing.end()
})
}
export async function requestDevQuit(
recordOrReader,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
requestImpl = request,
now = Date.now,
sleep = delay,
} = {},
) {
const deadline = now() + timeoutMs
const readRecord =
typeof recordOrReader === 'function'
? recordOrReader
: async () => recordOrReader
while (now() < deadline) {
let record
try {
record = validateInstanceRecord(await readRecord())
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
const remainingMs = Math.max(1, deadline - now())
let status
try {
status = await postDevQuit(record, remainingMs, requestImpl)
} catch (error) {
if (!TRANSIENT_REQUEST_ERRORS.has(error?.code)) {
throw safeRequestError(error)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
if (status === 202) {
return record
}
if (status !== 503) {
throw new Error(
`dev quit request returned HTTP ${status ?? 'without a status'}`,
)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev quit request to be accepted')
}
function portAcceptsConnections(port, timeoutMs) {
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port })
let settled = false
const finish = (isOpen) => {
if (settled) return
settled = true
socket.destroy()
resolve(isOpen)
}
socket.once('connect', () => finish(true))
socket.once('error', () => finish(false))
socket.setTimeout(Math.max(1, Math.min(1_000, timeoutMs)), () =>
finish(false),
)
})
}
export async function waitForPortClosed(
port,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
probePort = portAcceptsConnections,
now = Date.now,
sleep = delay,
} = {},
) {
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error('invalid loopback port')
}
const deadline = now() + timeoutMs
while (now() < deadline) {
const remainingMs = deadline - now()
if (!(await probePort(port, remainingMs))) {
return
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev endpoint to close')
}
export function createMacosShutdownController({
requestQuit,
waitForPortClosed: waitForClosed,
waitForChildExit,
terminateGroup,
finalizeGroup = terminateGroup,
exit,
report = (message) => console.error(message),
timeoutMs = DEFAULT_TIMEOUT_MS,
forceTimeoutMs = 2_000,
now = Date.now,
}) {
let signalCount = 0
let forced = false
let forcedPromise
let groupFinalizationPromise
const remainingBudget = (deadline) => {
const remaining = deadline - now()
if (remaining <= 0) {
throw new Error('timed out waiting for graceful dev shutdown')
}
return remaining
}
const finalizeOnce = (operation, budget) => {
groupFinalizationPromise ??= Promise.resolve().then(() => operation(budget))
return groupFinalizationPromise
}
const gracefulShutdown = async () => {
const deadline = now() + timeoutMs
try {
const record = await requestQuit(remainingBudget(deadline))
if (forced) return false
await waitForClosed(record.port, remainingBudget(deadline))
if (forced) return false
await waitForChildExit(remainingBudget(deadline))
if (forced) return false
await finalizeOnce(finalizeGroup, remainingBudget(deadline))
if (forced) return false
exit(130)
return true
} catch (error) {
report(
`Graceful dev shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
)
return false
}
}
return {
handleSigint() {
signalCount += 1
if (signalCount === 1) {
return gracefulShutdown()
}
if (!forcedPromise) {
forced = true
forcedPromise = finalizeOnce(terminateGroup, forceTimeoutMs).then(
() => {
exit(130)
return true
},
)
}
return forcedPromise
},
}
}
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise()
return
}
const error = new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`,
)
error.exitCode = code
reject(error)
})
})
}
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
export function windowsElevationInvocation(
installer,
environment = process.env,
) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
+566
View File
@@ -0,0 +1,566 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
createMacosShutdownController,
readPrivateInstanceRecord,
requestDevQuit,
waitForPortClosed,
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
const FORCE_GRACE_MS = 2_000
const SHUTDOWN_TIMEOUT_MS = 10_000
const FORCE_POLL_MS = 50
const ANCHOR_EXIT_RESERVE_MS = 100
export function buildTauriInvocation(
mode,
environment = process.env,
platform = process.platform,
) {
const env = { ...environment, RUST_BACKTRACE: 'full' }
const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev'
const args = ['exec', 'tauri', 'dev', '-f', features]
if (mode === 'trace') {
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
return {
command: 'pnpm',
args,
env,
detached: platform === 'darwin',
}
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function childLifecycle(child) {
const spawned = new Promise((resolve, reject) => {
child.once('spawn', resolve)
child.once('error', reject)
})
spawned.catch(() => {})
const result = new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve({ code, signal }))
})
result.catch(() => {})
return { result, spawned }
}
function waitForPromise(promise, timeoutMs, message) {
if (timeoutMs <= 0) return Promise.reject(new Error(message))
let timeout
return Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timeout))
}
function signalDetachedGroup(childPid, signal, signalGroup) {
if (!Number.isInteger(childPid) || childPid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
try {
signalGroup(-childPid, signal)
return true
} catch (error) {
if (error?.code === 'ESRCH') return false
throw error
}
}
export async function terminateDetachedGroup(
childPid,
{
signalGroup = process.kill.bind(process),
sleep = wait,
now = Date.now,
graceMs = FORCE_GRACE_MS,
pollMs = FORCE_POLL_MS,
} = {},
) {
if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return
const deadline = now() + graceMs
while (now() < deadline) {
await sleep(Math.min(pollMs, deadline - now()))
if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) {
return
}
}
signalDetachedGroup(childPid, 'SIGKILL', signalGroup)
}
export function mirrorChildExit(
{ code, signal },
{
exit = (exitCode) => process.exit(exitCode),
signalSelf = (childSignal) => {
process.removeAllListeners(childSignal)
process.kill(process.pid, childSignal)
},
} = {},
) {
if (code !== null) {
exit(code)
return
}
if (signal) {
signalSelf(signal)
return
}
exit(1)
}
function defaultInstallSigint(handler) {
process.on('SIGINT', handler)
return () => process.off('SIGINT', handler)
}
function defaultInstallSigterm(handler) {
process.on('SIGTERM', handler)
return () => process.off('SIGTERM', handler)
}
function defaultInstallMessage(handler) {
process.on('message', handler)
return () => process.off('message', handler)
}
function defaultInstallDisconnect(handler) {
process.on('disconnect', handler)
return () => process.off('disconnect', handler)
}
function sendToParent(message) {
if (process.connected && process.send) process.send(message, () => {})
}
export async function runDevAnchor(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs --anchor <dev|trace|sidecar>')
}
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const send = dependencies.send ?? sendToParent
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const ownPid = dependencies.ownPid ?? process.pid
const terminateGroup =
dependencies.terminateGroup ??
((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS }))
let released = false
let disconnectCleanup
const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm
installSigterm(() => {})
const installMessage = dependencies.installMessage ?? defaultInstallMessage
installMessage((message) => {
if (message?.type === 'release' && !disconnectCleanup) {
released = true
exit(0)
}
})
const installDisconnect =
dependencies.installDisconnect ?? defaultInstallDisconnect
installDisconnect(() => {
if (released || disconnectCleanup) return
disconnectCleanup = Promise.resolve()
.then(() => terminateGroup(ownPid))
.then(() => exit(1))
.catch((error) => {
report(`Dev anchor cleanup failed: ${error.message}`)
exit(1)
})
})
const invocation = buildTauriInvocation(mode, environment, 'darwin')
let inner
try {
inner = spawnChild(invocation.command, invocation.args, {
env: invocation.env,
detached: false,
stdio: 'inherit',
})
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
if (!inner || typeof inner.once !== 'function') {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
const innerLifecycle = childLifecycle(inner)
try {
await innerLifecycle.spawned
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
send({ type: 'ready' })
innerLifecycle.result.then(
({ code, signal }) => send({ type: 'inner-result', code, signal }),
() => send({ type: 'inner-error', code: 'INNER_FAILED' }),
)
}
function createAnchorChannel(anchor, anchorResult) {
let innerSettled = false
let readySettled = false
let resolveResult
let rejectResult
let resolveReady
let rejectReady
const innerResult = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
const ready = new Promise((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const settleInner = (callback, value) => {
if (innerSettled) return
innerSettled = true
callback(value)
}
const settleReady = (callback, value) => {
if (readySettled) return
readySettled = true
callback(value)
}
anchor.on('message', (message) => {
if (message?.type === 'ready') {
settleReady(resolveReady)
} else if (message?.type === 'inner-result') {
settleInner(resolveResult, {
code: message.code,
signal: message.signal,
})
} else if (message?.type === 'inner-error') {
const error = new Error(
`inner dev command failed (${message.code ?? 'UNKNOWN'})`,
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
}
})
anchorResult.then(
() => {
const error = new Error(
'dev group anchor exited before the inner command completed',
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
() => {
const error = new Error('dev group anchor failed')
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
)
ready.catch(() => {})
innerResult.catch(() => {})
return { innerResult, ready }
}
function sendRelease(anchor) {
return new Promise((resolve, reject) => {
if (typeof anchor.send !== 'function') {
reject(new Error('dev group anchor IPC is unavailable'))
return
}
anchor.send({ type: 'release' }, (error) => {
if (error) reject(new Error('dev group anchor release failed'))
else resolve()
})
})
}
function unsafeAnchorError() {
return new Error('dev group anchor identity is unsafe')
}
export async function runDevCommand(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs <dev|trace|sidecar>')
}
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit
let recordPath
let pendingSigints = 0
let dispatchSigint = () => {
pendingSigints += 1
}
let removeSigint = () => {}
if (platform === 'darwin') {
if (!environment.HOME) {
throw new Error('HOME is required to locate the dev instance record')
}
recordPath = join(
environment.HOME,
'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json',
)
const installSigint = dependencies.installSigint ?? defaultInstallSigint
removeSigint = installSigint(() => dispatchSigint()) ?? (() => {})
}
const tauriInvocation = buildTauriInvocation(mode, environment, platform)
const anchorInvocation = {
command: dependencies.execPath ?? process.execPath,
args: [fileURLToPath(import.meta.url), '--anchor', mode],
env: tauriInvocation.env,
detached: true,
}
const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation
let child
try {
child = spawnChild(invocation.command, invocation.args, {
shell: platform === 'win32',
env: invocation.env,
detached: invocation.detached,
stdio:
platform === 'darwin'
? ['inherit', 'inherit', 'inherit', 'ipc']
: 'inherit',
})
} catch (error) {
removeSigint()
throw error
}
if (!child || typeof child.once !== 'function') {
removeSigint()
throw new Error('dev command failed to return a child process')
}
const outerLifecycle = childLifecycle(child)
if (platform !== 'darwin') {
mirrorResult(await outerLifecycle.result)
return
}
let groupState = 'safe'
let finalKillSent = false
const anchorChannel = createAnchorChannel(child, outerLifecycle.result)
outerLifecycle.result.then(
() => {
if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe'
},
() => {
groupState = 'unsafe'
},
)
try {
await outerLifecycle.spawned
if (!Number.isInteger(child.pid) || child.pid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
await waitForPromise(
anchorChannel.ready,
SHUTDOWN_TIMEOUT_MS,
'timed out waiting for the dev group anchor to initialize',
)
} catch (error) {
removeSigint()
throw error
}
const anchorPid = child.pid
const innerResult = anchorChannel.innerResult
let intercepted = false
let observedShutdownOperation
let anchorFinalization
const signalGroup = dependencies.signalGroup ?? process.kill.bind(process)
const guardedSignalGroup = (pid, signal) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
if (signal === 'SIGKILL') finalKillSent = true
try {
signalGroup(pid, signal)
} catch (error) {
if (error?.code === 'ESRCH') {
groupState = 'unsafe'
throw unsafeAnchorError()
}
throw error
}
}
const assertGroupAbsent = () => {
try {
signalGroup(-anchorPid, 0)
} catch (error) {
if (error?.code === 'ESRCH') return
throw new Error('unable to verify that the dev process group exited', {
cause: error,
})
}
throw new Error('dev process group still exists after anchor exit')
}
const forceAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'terminating'
if (dependencies.terminateGroup) {
await dependencies.terminateGroup(anchorPid, budget)
return
}
const deadline = Date.now() + budget
await terminateDetachedGroup(anchorPid, {
graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS),
signalGroup: guardedSignalGroup,
})
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the dev group anchor to exit',
)
}
const releaseAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'released'
const deadline = Date.now() + budget
await waitForPromise(
sendRelease(child),
Math.max(1, deadline - Date.now()),
'timed out releasing the dev group anchor',
)
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the released dev group anchor',
)
assertGroupAbsent()
}
const finalizeAnchorOnce = (kind, budget) => {
anchorFinalization ??=
kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget)
return anchorFinalization
}
const getUid = dependencies.getUid ?? (() => process.getuid())
const requestQuit =
dependencies.requestQuit ??
((budget) =>
requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), {
timeoutMs: budget,
}))
const controller = createMacosShutdownController({
requestQuit,
waitForPortClosed: (port, budget) =>
(dependencies.waitForPortClosed ?? waitForPortClosed)(port, {
timeoutMs: budget,
}),
waitForChildExit: (budget) =>
waitForPromise(
innerResult,
budget,
'timed out waiting for the inner dev command to exit',
),
finalizeGroup: (budget) => finalizeAnchorOnce('release', budget),
terminateGroup: (budget) => finalizeAnchorOnce('force', budget),
exit,
report,
timeoutMs: SHUTDOWN_TIMEOUT_MS,
})
dispatchSigint = () => {
intercepted = true
const operation = controller.handleSigint()
if (operation !== observedShutdownOperation) {
observedShutdownOperation = operation
void operation.catch((error) => {
report(`Forced dev shutdown failed: ${error.message}`)
exit(1)
})
}
}
const queuedSigints = pendingSigints
pendingSigints = 0
for (let index = 0; index < queuedSigints; index += 1) {
dispatchSigint()
}
innerResult.then(
async (result) => {
if (intercepted) return
try {
await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS)
if (!intercepted) mirrorResult(result)
} catch (error) {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
}
},
(error) => {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
},
)
}
export async function prepareDevRuntime(mode, dependencies = {}) {
const ensureService = dependencies.ensureService ?? ensureDevelopmentService
const prepareService =
dependencies.prepareService ?? prepareDevelopmentService
let serviceDirectory
if (mode === 'service') {
serviceDirectory = await ensureService()
} else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') {
serviceDirectory = await prepareService()
}
return {
mode: mode === 'service' ? 'dev' : mode,
serviceDirectory,
}
}
async function run() {
if (process.argv[2] === '--anchor') {
await runDevAnchor(process.argv[3])
} else {
const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2])
if (serviceDirectory) {
process.env[developmentServiceDirectoryEnvironment] = serviceDirectory
}
await runDevCommand(mode)
}
}
if (
process.argv[1] &&
pathToFileURL(process.argv[1]).href === import.meta.url
) {
run().catch((error) => {
console.error(`Unable to start dev command: ${error.message}`)
process.exit(1)
})
}
+15 -68
View File
@@ -1,4 +1,4 @@
import { execSync } from 'child_process'
import { execFileSync, execSync } from 'child_process'
import { createHash } from 'crypto'
import fs from 'fs'
import fsp from 'fs/promises'
@@ -8,9 +8,9 @@ import zlib from 'zlib'
import AdmZip from 'adm-zip'
import { glob } from 'glob'
import { HttpsProxyAgent } from 'https-proxy-agent'
import fetch from 'node-fetch'
import { extract } from 'tar'
import { resolveServiceRelease } from './service-release.mjs'
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
/**
@@ -410,7 +410,7 @@ async function resolveSidecar(binInfo) {
throw new Error(`Expected binary not found in ${tempDir}`)
await fsp.rename(path.join(tempDir, candidate), sidecarPath)
}
if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`)
if (platform !== 'win32') await fsp.chmod(sidecarPath, 0o755)
log_success(`unzip finished: "${name}"`)
} else if (zipFile.endsWith('.tgz')) {
await extract({ cwd: tempDir, file: tempZip })
@@ -426,7 +426,7 @@ async function resolveSidecar(binInfo) {
if (!extracted) extracted = files[0]
if (!extracted) throw new Error(`Expected file not found in ${tempDir}`)
await fsp.rename(path.join(tempDir, extracted), sidecarPath)
execSync(`chmod 755 ${sidecarPath}`)
await fsp.chmod(sidecarPath, 0o755)
log_success(`tgz processed: "${name}"`)
} else {
// .gz
@@ -441,7 +441,8 @@ async function resolveSidecar(binInfo) {
})
.pipe(writeStream)
.on('finish', () => {
if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`)
if (platform !== 'win32')
execFileSync('chmod', ['755', sidecarPath])
resolve()
})
.on('error', (e) => {
@@ -577,12 +578,6 @@ const resolveServicePermission = async () => {
// =======================
// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback)
// =======================
const SERVICE_LATEST_URL =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/latest'
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
let SERVICE_VERSION
const SERVICE_BINARIES = [
'clash-verge-service',
'clash-verge-service-install',
@@ -598,53 +593,6 @@ function serviceFileInfo(name) {
}
}
function parseServiceVersionFromUrl(url) {
const match = url.match(/\/releases\/tag\/([^/?#]+)/)
return match ? decodeURIComponent(match[1]) : null
}
async function getLatestServiceVersion() {
if (!FORCE) {
const cached = await getCachedVersion('SERVICE_VERSION')
if (cached) {
SERVICE_VERSION = cached
return
}
}
const options = {}
const httpProxy =
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy
if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy)
try {
const response = await fetch(SERVICE_LATEST_URL, {
...options,
method: 'GET',
redirect: 'follow',
})
if (!response.ok)
throw new Error(
`Failed to fetch ${SERVICE_LATEST_URL}: ${response.status}`,
)
SERVICE_VERSION = parseServiceVersionFromUrl(response.url)
if (!SERVICE_VERSION)
throw new Error(
`Unable to resolve service release tag from ${response.url}`,
)
log_info(`Latest service version: ${SERVICE_VERSION}`)
await setCachedVersion('SERVICE_VERSION', SERVICE_VERSION)
} catch (err) {
log_error('Error fetching latest service version:', err.message)
process.exit(1)
}
}
async function findExtractedFile(dir, fileName) {
const entries = await fsp.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
@@ -667,16 +615,15 @@ async function resolveServiceBundle() {
}
})
if (!FORCE && files.every(({ targetPath }) => fs.existsSync(targetPath))) {
log_success('"clash-verge-service-ipc" already exists, skipping download')
return
}
await getLatestServiceVersion()
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${SERVICE_VERSION}-${SIDECAR_HOST}.${archiveExt}`
const downloadURL = `${SERVICE_URL_PREFIX}/${SERVICE_VERSION}/${archiveFile}`
const cargoManifest = await fsp.readFile(
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
platform,
)
const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc')
const tempArchive = path.join(tempDir, archiveFile)
+31 -13
View File
@@ -1,6 +1,6 @@
// scripts/publish-version.mjs
import { spawn } from 'child_process'
import { existsSync } from 'fs'
import { execFileSync, spawn } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import path from 'path'
const rootDir = process.cwd()
@@ -29,7 +29,27 @@ const runRelease = () =>
// 2. 判断是否需要打 tag
function isSemver(version) {
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version)
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
version,
)
}
function normalizeVersion(version) {
return version.startsWith('v') ? version : `v${version}`
}
function readPackageVersion() {
const pkg = JSON.parse(
readFileSync(path.join(rootDir, 'package.json'), 'utf8'),
)
return pkg.version
}
function validateTag(tag) {
if (!isSemver(tag)) {
console.error(`[ERROR]: Invalid git tag: ${tag}`)
process.exit(1)
}
}
async function run() {
@@ -37,22 +57,20 @@ async function run() {
let tag = null
if (versionArg === 'alpha') {
// 读取 package.json 里的主版本
const pkg = await import(path.join(rootDir, 'package.json'), {
assert: { type: 'json' },
})
tag = `v${pkg.default.version}-alpha`
// 读取 release-version.mjs 写入后的版本
tag = normalizeVersion(readPackageVersion())
} else if (isSemver(versionArg)) {
// 1.2.3 或 v1.2.3
tag = versionArg.startsWith('v') ? versionArg : `v${versionArg}`
// 1.2.3、v1.2.3、1.2.3-beta.1 或 1.2.3+build.1
tag = normalizeVersion(versionArg)
}
if (tag) {
validateTag(tag)
// 打 tag 并推送
const { execSync } = await import('child_process')
try {
execSync(`git tag ${tag}`, { stdio: 'inherit' })
execSync(`git push origin ${tag}`, { stdio: 'inherit' })
execFileSync('git', ['tag', tag], { stdio: 'inherit' })
execFileSync('git', ['push', 'origin', tag], { stdio: 'inherit' })
console.log(`[INFO]: Git tag ${tag} created and pushed.`)
} catch {
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`)
+23
View File
@@ -0,0 +1,23 @@
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
export function resolveServiceRelease(cargoManifest, host, platform) {
const dependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const packageVersion = dependency?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
if (!packageVersion) {
throw new Error(
'clash_verge_service_ipc dependency must declare an inline version',
)
}
const version = `v${packageVersion}`
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${version}-${host}.${archiveExt}`
return {
version,
archiveFile,
downloadURL: `${SERVICE_URL_PREFIX}/${version}/${archiveFile}`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { resolveServiceRelease } from './service-release.mjs'
test('service prebuild release follows the Cargo dependency version', async () => {
const cargoManifest = await readFile(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
)
const dependencyVersion = cargoManifest
.split(/\r?\n/)
.find((line) => line.startsWith('clash_verge_service_ipc ='))
?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
assert.ok(dependencyVersion)
const releaseVersion = `v${dependencyVersion}`
assert.deepEqual(
resolveServiceRelease(cargoManifest, 'x86_64-pc-windows-msvc', 'win32'),
{
version: releaseVersion,
archiveFile: `clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
downloadURL: `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${releaseVersion}/clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
},
)
})
test('service prebuild rejects a dependency without an explicit version', () => {
assert.throws(
() =>
resolveServiceRelease(
'clash_verge_service_ipc = { path = "../service" }',
'x86_64-pc-windows-msvc',
'win32',
),
/must declare an inline version/,
)
})
-104
View File
@@ -1,104 +0,0 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
const VERSION_URL =
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt'
const DEFAULT_CHANGELOG = 'Changelog.md'
const args = process.argv.slice(2)
const CHECK = args.includes('--check')
const DRY_RUN = args.includes('--dry-run')
const logError = (message, ...optionalParams) =>
console.error(message, ...optionalParams)
const logInfo = (message, ...optionalParams) =>
console.log(message, ...optionalParams)
function readArgValue(name) {
const index = args.indexOf(name)
if (index === -1) return null
return args[index + 1] ?? null
}
function normalizeVersion(version) {
const normalized = version.trim()
return normalized.startsWith('v') ? normalized : `v${normalized}`
}
async function fetchLatestVersion() {
const explicitVersion = readArgValue('--version')
if (explicitVersion) return normalizeVersion(explicitVersion)
const response = await fetch(VERSION_URL, {
method: 'GET',
})
if (!response.ok) {
throw new Error(`Failed to fetch ${VERSION_URL}: ${response.status}`)
}
return normalizeVersion(await response.text())
}
function replaceExistingLine(content, version) {
const mihomoLine =
/^(\s*-\s*(?:\*\*)?\s*Mihomo\(Meta\)\s*内核升级至\s*)v?\d+(?:\.\d+)+(?:[-+][^\s*]+)?((?:\*\*)?\s*)$/m
if (!mihomoLine.test(content)) return null
return content.replace(mihomoLine, `$1${version}$2`)
}
function insertMihomoLine(content, version) {
const title = /^## .+$/m
const match = content.match(title)
if (!match || match.index === undefined) {
throw new Error('could not find changelog title')
}
const titleEnd = match.index + match[0].length
const before = content.slice(0, titleEnd)
const after = content.slice(titleEnd).replace(/^\n*/, '\n\n')
return `${before}\n\n- **Mihomo(Meta) 内核升级至 ${version}**${after}`
}
async function main() {
const cwd = process.cwd()
const changelogArg = readArgValue('--file')
const changelogPath = path.join(cwd, changelogArg || DEFAULT_CHANGELOG)
if (!fs.existsSync(changelogPath)) {
throw new Error(`could not find ${path.relative(cwd, changelogPath)}`)
}
const version = await fetchLatestVersion()
const content = await fsp.readFile(changelogPath, 'utf-8')
const nextContent =
replaceExistingLine(content, version) ?? insertMihomoLine(content, version)
if (nextContent === content) {
logInfo(`Mihomo version in changelog is already ${version}`)
return
}
if (CHECK) {
logError(`Mihomo version in changelog is not ${version}`)
process.exit(1)
}
if (DRY_RUN) {
logInfo(`Would update Mihomo version in changelog to ${version}`)
return
}
await fsp.writeFile(changelogPath, nextContent)
logInfo(`Updated Mihomo version in changelog to ${version}`)
}
main().catch((error) => {
logError('sync-mihomo-version failed:', error.message)
process.exit(1)
})
+14 -10
View File
@@ -33,7 +33,7 @@ async function sendTelegramNotification() {
log_info(`Download URL: ${downloadUrl}`)
// 读取发布说明和下载地址
let releaseContent = ''
let releaseContent
try {
releaseContent = readFileSync('release.txt', 'utf-8')
log_info('成功读取 release.txt 文件')
@@ -92,15 +92,19 @@ async function sendTelegramNotification() {
function sanitizeTelegramHTML(content) {
// Telegram supports: b, strong, i, em, u, ins, s, strike, del,
// a, code, pre, blockquote, tg-spoiler, tg-emoji
const allowedTags =
/^\/?(b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(\s|>|$)/i
return content.replace(/<\/?[^>]*>/g, (tag) => {
const inner = tag.replace(/^<\/?/, '').replace(/>$/, '')
if (allowedTags.test(inner) || allowedTags.test(tag.slice(1))) {
return tag
}
// Escape unsupported tags so they display as text
return tag.replace(/</g, '&lt;').replace(/>/g, '&gt;')
const allowedTag =
/^<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?:\s[^<>]*)?>$/i
// Match a well-formed tag (name first, no nested angle brackets) OR a lone
// '<'/'>'. Matching lone brackets is what keeps a stray '<' in the changelog
// (e.g. "原始配置 < Merge") from greedily swallowing the next real tag and
// leaving its closing tag orphaned ("Unexpected end tag").
return content.replace(/<\/?[a-z][^<>]*>|[<>]/gi, (token) => {
if (token === '<') return '&lt;'
if (token === '>') return '&gt;'
// Keep supported tags; escape everything else so it displays as text.
return allowedTag.test(token)
? token
: token.replace(/</g, '&lt;').replace(/>/g, '&gt;')
})
}
-1
View File
@@ -1,5 +1,4 @@
import { context, getOctokit } from '@actions/github'
import fetch from 'node-fetch'
import { resolveUpdateLog } from './updatelog.mjs'
+113 -31
View File
@@ -1,5 +1,4 @@
import { getOctokit, context } from '@actions/github'
import fetch from 'node-fetch'
import { context, getOctokit } from '@actions/github'
import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs'
@@ -92,21 +91,38 @@ async function processRelease(github, options, tag, isAlpha) {
),
pub_date: new Date().toISOString(),
platforms: {
win64: { signature: '', url: '' }, // compatible with older formats
linux: { signature: '', url: '' }, // compatible with older formats
darwin: { signature: '', url: '' }, // compatible with older formats
'darwin-aarch64': { signature: '', url: '' },
'darwin-intel': { signature: '', url: '' },
// platform format:
// standard: "{os}-{arch}-{installer}",
// fallback: "{os}-{arch}"
'darwin-x86_64': { signature: '', url: '' },
'linux-x86_64': { signature: '', url: '' },
'darwin-x86_64-app': { signature: '', url: '' },
'darwin-aarch64': { signature: '', url: '' },
'darwin-aarch64-app': { signature: '', url: '' },
'linux-x86': { signature: '', url: '' },
'linux-x86-deb': { signature: '', url: '' },
'linux-x86-rpm': { signature: '', url: '' },
'linux-x86_64': { signature: '', url: '' },
'linux-x86_64-deb': { signature: '', url: '' },
'linux-x86_64-rpm': { signature: '', url: '' },
'linux-i686': { signature: '', url: '' },
'linux-i686-deb': { signature: '', url: '' },
'linux-i686-rpm': { signature: '', url: '' },
'linux-aarch64': { signature: '', url: '' },
'linux-aarch64-deb': { signature: '', url: '' },
'linux-aarch64-rpm': { signature: '', url: '' },
'linux-armv7': { signature: '', url: '' },
'windows-x86_64': { signature: '', url: '' },
'windows-aarch64': { signature: '', url: '' },
'linux-armv7-deb': { signature: '', url: '' },
'linux-armv7-rpm': { signature: '', url: '' },
'windows-x86': { signature: '', url: '' },
'windows-x86-nsis': { signature: '', url: '' },
'windows-x86_64': { signature: '', url: '' },
'windows-x86_64-nsis': { signature: '', url: '' },
'windows-aarch64': { signature: '', url: '' },
'windows-aarch64-nsis': { signature: '', url: '' },
'windows-i686': { signature: '', url: '' },
'windows-i686-nsis': { signature: '', url: '' },
},
}
@@ -116,73 +132,139 @@ async function processRelease(github, options, tag, isAlpha) {
// Process all the platform URL and signature data
// win64 url
if (name.endsWith('x64-setup.exe')) {
updateData.platforms.win64.url = browser_download_url
updateData.platforms['windows-x86_64'].url = browser_download_url
updateData.platforms['windows-x86_64-nsis'].url = browser_download_url
}
// win64 signature
if (name.endsWith('x64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms.win64.signature = sig
updateData.platforms['windows-x86_64'].signature = sig
updateData.platforms['windows-x86_64-nsis'].signature = sig
}
// win32 url
if (name.endsWith('x86-setup.exe')) {
updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms['windows-i686'].url = browser_download_url
updateData.platforms['windows-x86-nsis'].url = browser_download_url
updateData.platforms['windows-i686-nsis'].url = browser_download_url
}
// win32 signature
if (name.endsWith('x86-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86'].signature = sig
updateData.platforms['windows-i686'].signature = sig
updateData.platforms['windows-x86-nsis'].signature = sig
updateData.platforms['windows-i686-nsis'].signature = sig
}
// win arm url
if (name.endsWith('arm64-setup.exe')) {
updateData.platforms['windows-aarch64'].url = browser_download_url
updateData.platforms['windows-aarch64-nsis'].url = browser_download_url
}
// win arm signature
if (name.endsWith('arm64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-aarch64'].signature = sig
updateData.platforms['windows-aarch64-nsis'].signature = sig
}
// darwin url (intel)
if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) {
updateData.platforms.darwin.url = browser_download_url
updateData.platforms['darwin-intel'].url = browser_download_url
updateData.platforms['darwin-x86_64'].url = browser_download_url
updateData.platforms['darwin-x86_64-app'].url = browser_download_url
}
// darwin signature (intel)
if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) {
const sig = await getSignature(browser_download_url)
updateData.platforms.darwin.signature = sig
updateData.platforms['darwin-intel'].signature = sig
updateData.platforms['darwin-x86_64'].signature = sig
updateData.platforms['darwin-x86_64-app'].signature = sig
}
// darwin url (aarch)
if (name.endsWith('aarch64.app.tar.gz')) {
updateData.platforms['darwin-aarch64'].url = browser_download_url
// 使linux可以检查更新
updateData.platforms.linux.url = browser_download_url
updateData.platforms['linux-x86_64'].url = browser_download_url
updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms['linux-aarch64'].url = browser_download_url
updateData.platforms['linux-armv7'].url = browser_download_url
updateData.platforms['darwin-aarch64-app'].url = browser_download_url
}
// darwin signature (aarch)
if (name.endsWith('aarch64.app.tar.gz.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['darwin-aarch64'].signature = sig
updateData.platforms.linux.signature = sig
updateData.platforms['linux-x86_64'].signature = sig
updateData.platforms['darwin-aarch64-app'].signature = sig
}
// Linux x86
if (name.endsWith('i386.deb')) {
updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms['linux-x86-deb'].url = browser_download_url
updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms['linux-i686-deb'].url = browser_download_url
}
if (name.endsWith('i386.deb.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-x86'].signature = sig
updateData.platforms['linux-x86-deb'].signature = sig
updateData.platforms['linux-i686'].signature = sig
updateData.platforms['linux-i686-deb'].signature = browser_download_url
}
if (name.endsWith('i386.rpm')) {
updateData.platforms['linux-x86-rpm'].url = browser_download_url
updateData.platforms['linux-i686-rpm'].url = browser_download_url
}
if (name.endsWith('i386.rpm.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-x86-rpm'].signature = sig
updateData.platforms['linux-i686-rpm'].signature = sig
}
// Linux x86_64
if (name.endsWith('amd64.deb')) {
updateData.platforms['linux-x86_64'].url = browser_download_url
updateData.platforms['linux-x86_64-deb'].url = browser_download_url
}
if (name.endsWith('amd64.deb.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-x86_64'].signature = sig
updateData.platforms['linux-x86_64-deb'].signature = sig
}
if (name.endsWith('x86_64.rpm')) {
updateData.platforms['linux-x86_64-rpm'].url = browser_download_url
}
if (name.endsWith('x86_64.rpm.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-x86_64-rpm'].signature = sig
}
// Linux aarch64
if (name.endsWith('arm64.deb')) {
updateData.platforms['linux-aarch64'].url = browser_download_url
updateData.platforms['linux-aarch64-deb'].url = browser_download_url
}
if (name.endsWith('arm64.deb.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-aarch64'].signature = sig
updateData.platforms['linux-aarch64-deb'].signature = sig
}
if (name.endsWith('aarch64.rpm')) {
updateData.platforms['linux-aarch64-rpm'].url = browser_download_url
}
if (name.endsWith('aarch64.rpm.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-aarch64-rpm'].signature = sig
}
// Linux armv7
if (name.endsWith('armhf.deb')) {
updateData.platforms['linux-armv7'].url = browser_download_url
updateData.platforms['linux-armv7-deb'].url = browser_download_url
}
if (name.endsWith('armhf.deb.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-armv7'].signature = sig
updateData.platforms['linux-armv7-deb'].signature = sig
}
if (name.endsWith('armhfp.rpm')) {
updateData.platforms['linux-armv7-rpm'].url = browser_download_url
}
if (name.endsWith('armhfp.rpm.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['linux-armv7-rpm'].signature = sig
}
})
@@ -204,7 +286,7 @@ async function processRelease(github, options, tag, isAlpha) {
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) {
updateDataNew.platforms[key].url =
'https://update.hwdns.net/' + value.url
`https://update.hwdns.net/${value.url}`
} else {
console.log(`[Error]: updateDataNew.platforms.${key} is null`)
}
+48 -43
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.0"
version = "2.5.3"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
default-run = "clash-verge"
build = "build.rs"
edition = "2024"
rust-version = "1.91"
rust-version = "1.95"
[lib]
name = "app_lib"
@@ -17,7 +17,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
verge-dev = ["clash_verge_logger/color"]
verge-dev = ["clash_verge_logger/color", "clash_verge_service_ipc/development-channel"]
dev-sidecar = ["verge-dev"]
tauri-dev = []
tokio-trace = ["console-subscriber"]
clippy = ["tauri/test"]
@@ -27,7 +28,7 @@ tracing = []
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
@@ -55,69 +56,68 @@ serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
warp = { version = "0.4.2", features = ["server"] }
open = "5.3.3"
socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.4.0"
dunce = "1.0.5"
nanoid = "0.5"
chrono = "0.4.44"
boa_engine = "0.21.0"
chrono = "0.4.45"
boa_engine = "0.21.1"
once_cell = { version = "1.21.4", features = ["parking_lot"] }
delay_timer = "0.11.6"
percent-encoding = "2.3.2"
reqwest = { version = "0.13.2", features = [
"json",
"cookies",
"rustls",
"form",
] }
regex = "1.12.3"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "0.5.4", features = [
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] }
regex = "1.13.1"
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs", branch = "main", features = [
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-fs = "2.5.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.7"
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-window-state = "2.4.1"
zip = "8.3.1"
zip = "8.6.0"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.10.3", features = ["std"] }
base64 = "0.22.1"
getrandom = "0.4.2"
futures = "0.3.32"
aes-gcm = { version = "0.11.0" }
base64 = "0.23.0"
getrandom = "0.4.3"
futures = "0.3.33"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3"
tokio-stream = "0.1.18"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.5.7"
tauri-plugin-http = "2.5.9"
console-subscriber = { version = "0.5.0", optional = true }
tauri-plugin-devtools = { version = "2.0.1" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo" }
tauri-plugin-devtools = { version = "2.1.0" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "refactor-use-reqwest-ipc" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.89"
clash_verge_service_ipc = { version = "2.3.0", features = [
async-trait = "0.1.91"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.1", version = "2.6.1", features = [
"client",
], git = "https://github.com/clash-verge-rev/clash-verge-service-ipc" }
arc-swap = "1.9.0"
tokio-rustls = "0.26"
rustls = { version = "0.23", features = ["ring"] }
webpki-roots = "1.0"
rust_iso3166 = "0.1.14"
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.4"
rustls = { version = "0.23.42", features = ["ring"] }
webpki-roots = "1.0.9"
rust_iso3166 = "0.2.0"
# Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
bytes = "1.11.1"
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = [
objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
"NSDictionary",
"NSAttributedString",
] }
objc2-app-kit = { version = "0.3", features = [
objc2-app-kit = { version = "0.3.2", features = [
"NSAttributedString",
"NSStatusItem",
"NSStatusBarButton",
@@ -129,6 +129,7 @@ objc2-app-kit = { version = "0.3", features = [
"NSFontDescriptor",
"NSColor",
"NSParagraphStyle",
"NSStringDrawing",
"NSText",
] }
@@ -136,15 +137,19 @@ objc2-app-kit = { version = "0.3", features = [
deelevate = { workspace = true }
runas = "=1.2.0"
winreg = "0.56.0"
windows-service = "0.8.1"
windows = { version = "0.62.2", features = ["Win32_Globalization"] }
windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.1"
tauri-plugin-updater = "2.10.0"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.10.1"
[dev-dependencies]
criterion = { workspace = true }
tokio = { workspace = true, features = ["test-util"] }
warp = { version = "0.4.3", features = ["test"] }
[lints]
workspace = true
+33 -1
View File
@@ -1,4 +1,34 @@
fn main() {
fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
&& std::env::var_os("CARGO_FEATURE_CLIPPY").is_some()
{
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses.
// Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect.
let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml");
std::fs::write(
&manifest_path,
r#"<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>
"#,
)?;
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
}
#[cfg(feature = "clippy")]
{
println!("cargo:warning=Skipping tauri_build during Clippy");
@@ -6,4 +36,6 @@ fn main() {
#[cfg(not(feature = "clippy"))]
tauri_build::build();
Ok(())
}
-1
View File
@@ -12,7 +12,6 @@
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart",
"deep-link:default",
"autostart:allow-enable",
"autostart:allow-disable",
+1 -1
View File
@@ -54,6 +54,7 @@
"core:window:allow-set-cursor-position",
"core:window:allow-set-ignore-cursor-events",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-unmaximize",
@@ -73,7 +74,6 @@
"global-shortcut:allow-register-all",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"process:allow-restart",
"process:allow-exit",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
+16 -1
View File
@@ -478,7 +478,22 @@ Function .onInit
${EndIf}
!if "${DISPLAYLANGUAGESELECTOR}" == "true"
!insertmacro MUI_LANGDLL_DISPLAY
; Auto-update forwards the app's UI language as `/LANG=<NSIS-lang-id>` so
; the installer uses it directly and skips the interactive language
; selector, letting the update start without prompting the user.
; See `src-tauri/src/core/updater.rs` (`nsis_language_id`).
${GetOptions} $CMDLINE "/LANG=" $0
${IfNot} ${Errors}
${If} $0 == "1033"
${OrIf} $0 == "1049"
${OrIf} $0 == "2052"
StrCpy $LANGUAGE $0
${Else}
!insertmacro MUI_LANGDLL_DISPLAY
${EndIf}
${Else}
!insertmacro MUI_LANGDLL_DISPLAY
${EndIf}
!endif
!insertmacro SetContext
-20
View File
@@ -32,26 +32,6 @@ pub fn open_web_url(url: String) -> CmdResult<()> {
open::that(url.as_str()).stringify_err()
}
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
/// 打开 Verge 最新日志
#[tauri::command]
pub async fn open_app_log() -> CmdResult<()> {
let log_path = dirs::app_latest_log().stringify_err()?;
#[cfg(target_os = "windows")]
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
open::that(log_path).stringify_err()
}
// TODO 后续可以为前端提供接口,当前作为托盘菜单使用
/// 打开 Clash 最新日志
#[tauri::command]
pub async fn open_core_log() -> CmdResult<()> {
let log_path = dirs::clash_latest_log().stringify_err()?;
#[cfg(target_os = "windows")]
let log_path = crate::utils::help::snapshot_path(&log_path).stringify_err()?;
open::that(log_path).stringify_err()
}
/// 打开/关闭开发者工具
#[tauri::command]
pub fn open_devtools(app_handle: AppHandle) {
+35 -12
View File
@@ -1,6 +1,6 @@
use super::CmdResult;
use super::{CmdResult, WithErrorCode as _, coded_error};
use crate::feat;
use crate::utils::dirs;
use crate::utils::{dirs, yaml_emitter};
use crate::{
cmd::StringifyErr as _,
config::{ClashInfo, Config},
@@ -32,14 +32,29 @@ pub async fn get_clash_info() -> CmdResult<ClashInfo> {
/// 修改Clash配置
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload).await.stringify_err()
feat::patch_clash(&payload)
.await
.with_error_code("CLASH_CONFIG_UPDATE_FAILED")
}
/// 修改Clash模式
///
/// 将 `change_clash_mode` 的失败上抛给前端,使前端 `catch` 能真正感知后端 PATCH 失败
/// 并提示用户(此前命令始终返回 `Ok(())`,吞掉了后端错误)。
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
feat::change_clash_mode(payload).await;
Ok(())
feat::change_clash_mode(payload)
.await
.with_error_code("CLASH_MODE_UPDATE_FAILED")
}
/// 获取当前 Clash 模式(容错读取)
///
/// 直接读取已保存的 clash 配置中的 `mode`,绕开 mihomo `/configs` 的严格
/// `BaseConfig` 反序列化,作为主页 mode 显示的兜底来源。
#[tauri::command]
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
}
/// 切换Clash核心
@@ -63,7 +78,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
let error_msg: String = format!("Core changed but failed to restart: {err}").into();
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
logging!(error, Type::Core, "{error_msg}");
Ok(Some(error_msg))
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
}
}
}
@@ -71,7 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
let error_msg: String = err;
logging!(error, Type::Core, "failed to change core: {error_msg}");
handle::Handle::notice_message("config_core::change_error", error_msg.clone());
Ok(Some(error_msg))
Ok(Some(coded_error("CORE_CHANGE_FAILED", error_msg)))
}
}
}
@@ -79,7 +94,10 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<String>>
/// 启动核心
#[tauri::command]
pub async fn start_core() -> CmdResult {
let result = CoreManager::global().start_core().await.stringify_err();
let result = CoreManager::global()
.start_core()
.await
.with_error_code("CORE_START_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -90,7 +108,10 @@ pub async fn start_core() -> CmdResult {
#[tauri::command]
pub async fn stop_core() -> CmdResult {
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
let result = CoreManager::global().stop_core().await.stringify_err();
let result = CoreManager::global()
.stop_core()
.await
.with_error_code("CORE_STOP_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -101,7 +122,10 @@ pub async fn stop_core() -> CmdResult {
#[tauri::command]
pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, Config::profiles().await.data_arc().save_file().await);
let result = CoreManager::global().restart_core().await.stringify_err();
let result = CoreManager::global()
.restart_core()
.await
.with_error_code("CORE_RESTART_FAILED");
if result.is_ok() {
handle::Handle::refresh_clash();
}
@@ -125,14 +149,13 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
#[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
use crate::utils::dirs;
use serde_yaml_ng;
use tokio::fs;
// 获取DNS配置文件路径
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
// 保存DNS配置到文件
let yaml_str = serde_yaml_ng::to_string(&dns_config).stringify_err()?;
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
+15
View File
@@ -0,0 +1,15 @@
use super::{CmdResult, StringifyErr as _};
use crate::{
core::listener::{ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome},
feat,
};
#[tauri::command]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err()
}
#[tauri::command]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings).await.stringify_err()
}
+31
View File
@@ -3,11 +3,18 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, String>;
const USER_ERROR_PREFIX: &str = "CVR_ERROR:";
pub fn coded_error(code: &str, detail: impl std::fmt::Display) -> String {
format!("{USER_ERROR_PREFIX}{code}\n{detail}").into()
}
// Command modules
pub mod app;
pub mod backup;
pub mod clash;
pub mod lightweight;
pub mod listener;
pub mod media_unlock_checker;
pub mod network;
pub mod profile;
@@ -26,6 +33,7 @@ pub use app::*;
pub use backup::*;
pub use clash::*;
pub use lightweight::*;
pub use listener::*;
pub use media_unlock_checker::*;
pub use network::*;
pub use profile::*;
@@ -46,6 +54,10 @@ pub trait StringifyErr<T> {
F: Fn(&str);
}
pub trait WithErrorCode<T> {
fn with_error_code(self, code: &str) -> CmdResult<T>;
}
impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
fn stringify_err(self) -> CmdResult<T> {
self.map_err(|e| e.to_string().into())
@@ -62,3 +74,22 @@ impl<T, E: std::fmt::Display> StringifyErr<T> for Result<T, E> {
})
}
}
impl<T, E: std::fmt::Display> WithErrorCode<T> for Result<T, E> {
fn with_error_code(self, code: &str) -> CmdResult<T> {
self.map_err(|error| coded_error(code, error))
}
}
#[cfg(test)]
mod tests {
use super::coded_error;
#[test]
fn coded_error_preserves_stable_code_and_diagnostic_detail() {
assert_eq!(
coded_error("CORE_RESTART_FAILED", "connection refused"),
"CVR_ERROR:CORE_RESTART_FAILED\nconnection refused"
);
}
}
+5 -6
View File
@@ -5,7 +5,6 @@ use clash_verge_logging::{Type, logging};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use std::net::TcpListener;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
@@ -60,6 +59,11 @@ pub async fn get_auto_proxy() -> CmdResult<Mapping> {
Ok(map)
}
#[tauri::command]
pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err()
}
/// 获取系统主机名
#[tauri::command]
pub fn get_system_hostname() -> String {
@@ -99,8 +103,3 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
Ok(result)
}
#[tauri::command]
pub fn is_port_in_use(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_err()
}
+226 -71
View File
@@ -1,6 +1,6 @@
use super::CmdResult;
use super::StringifyErr as _;
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
use crate::config::profiles;
use crate::utils::window_manager::WindowManager;
use crate::{
config::{
@@ -15,15 +15,22 @@ use crate::{
feat,
utils::{dirs, help},
};
use clash_verge_draft::SharedDraft;
use clash_verge_logging::{Type, logging};
use clash_verge_draft::{Draft, SharedDraft};
use clash_verge_logging::{Type, logging, logging_error};
use scopeguard::defer;
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
fn profile_import_error(err: &anyhow::Error) -> std::string::String {
if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) {
return cause.to_string();
}
format!("导入订阅失败: {err:#}")
}
#[tauri::command]
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
logging!(debug, Type::Cmd, "获取配置文件列表");
@@ -52,7 +59,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
}
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
Err(e.to_string().into())
Err(coded_error("PROFILE_ENHANCE_FAILED", e))
}
}
}
@@ -70,34 +77,23 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
}
Err(e) => {
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
return Err(format!("导入订阅失败: {}", e).into());
return Err(coded_error("PROFILE_IMPORT_FAILED", profile_import_error(&e)));
}
};
match profiles_append_item_safe(item).await {
Ok(_) => match profiles_save_file_safe().await {
Ok(_) => {
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
}
Err(e) => {
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
}
},
Err(e) => {
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
return Err(format!("导入订阅失败: {}", e).into());
}
if let Err(e) = profiles_append_item_safe(item).await {
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
}
if let Some(uid) = &item.uid {
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid);
if let Err(e) = profiles_save_file_safe().await {
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
}
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
logging_error!(Type::Timer, Timer::global().refresh().await);
// 异步保存配置文件并发送全局通知
if let Some(uid) = &item.uid {
// 延迟发送,确保文件已完全写入
tokio::time::sleep(Duration::from_millis(100)).await;
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
handle::Handle::notify_profile_changed(uid);
}
@@ -116,7 +112,7 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
}
Err(err) => {
logging!(error, Type::Cmd, "重新排序配置文件失败: {}", err);
Err(format!("重新排序配置文件失败: {}", err).into())
Err(coded_error("PROFILE_REORDER_FAILED", err))
}
}
}
@@ -127,7 +123,10 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => {
profiles_save_file_safe().await.stringify_err()?;
profiles_save_file_safe()
.await
.with_error_code("PROFILE_CREATE_FAILED")?;
logging_error!(Type::Timer, Timer::global().refresh().await);
// 发送配置变更通知
if let Some(uid) = &item.uid {
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
@@ -135,10 +134,7 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
}
Ok(())
}
Err(err) => match err.to_string().as_str() {
"the file already exists" => Err("the file already exists".into()),
_ => Err(format!("add profile error: {err}").into()),
},
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
}
}
@@ -149,7 +145,7 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
Ok(_) => Ok(()),
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
Err(e.to_string().into())
Err(coded_error("PROFILE_UPDATE_FAILED", e))
}
}
}
@@ -158,8 +154,12 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
// 使用Send-safe helper函数
let should_update = profiles_delete_item_safe(&index).await.stringify_err()?;
profiles_save_file_safe().await.stringify_err()?;
let should_update = profiles_delete_item_safe(&index)
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
profiles_save_file_safe()
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
}
@@ -178,15 +178,18 @@ pub async fn delete_profile(index: String) -> CmdResult {
Ok(outcome) => {
logging!(warn, Type::Cmd, "删除订阅后更新配置失败: {}", outcome);
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
return Err(outcome.to_string().into());
return Err(coded_error("PROFILE_DELETE_FAILED", outcome));
}
Err(e) => {
logging!(error, Type::Cmd, "{}", e);
return Err(e.to_string().into());
return Err(coded_error("PROFILE_DELETE_FAILED", e));
}
}
}
Timer::global().refresh().await.stringify_err()?;
Timer::global()
.refresh()
.await
.with_error_code("PROFILE_DELETE_FAILED")?;
Ok(())
}
@@ -210,17 +213,29 @@ async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> {
Ok(())
}
async fn commit_current_profile(profiles: &Draft<IProfiles>, current: Option<String>) -> anyhow::Result<()> {
profiles.discard();
let Some(current) = current else {
return Ok(());
};
profiles
.with_data_modify(|mut committed| async move {
committed.patch_config(&IProfiles {
current: Some(current),
items: None,
});
Ok((committed, ()))
})
.await
}
async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationOutcome> {
Config::profiles().await.apply();
handle::Handle::refresh_clash();
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
}
if let Err(e) = Tray::global().update_menu().await {
logging!(warn, Type::Cmd, "Warning: 异步更新托盘菜单失败: {e}");
}
commit_current_profile(&Config::profiles().await, current_value.cloned())
.await
.stringify_err()?;
// Runtime refresh and tray rebuilding happen after saved node selections are restored.
profiles::activate_selected_nodes().stringify_err()?;
if let Err(e) = profiles_save_file_safe().await {
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
@@ -265,12 +280,14 @@ async fn handle_update_error<E: std::fmt::Display>(
Ok(ValidationOutcome::invalid_from_message(message))
}
async fn handle_timeout(current_profile: Option<&String>) -> CmdResult<ValidationOutcome> {
let timeout_msg: String = "配置更新超时(30秒),可能是配置验证或核心通信阻塞".into();
logging!(error, Type::Cmd, "{}", timeout_msg);
discard_and_restore(current_profile).await?;
handle::Handle::notice_message("config_validate::timeout", timeout_msg.clone());
Ok(ValidationOutcome::invalid_from_message(timeout_msg))
async fn run_profile_config_update_transition<Update, UpdateFuture>(
update_config: Update,
) -> anyhow::Result<ValidationOutcome>
where
Update: FnOnce() -> UpdateFuture,
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
{
update_config().await
}
async fn perform_config_update(
@@ -280,14 +297,12 @@ async fn perform_config_update(
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result =
tokio::time::timeout(Duration::from_secs(30), CoreManager::global().update_config_forced()).await;
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result {
Ok(Ok(outcome)) if outcome.is_valid() => handle_success(current_value).await,
Ok(Ok(outcome)) => handle_validation_failure(outcome, current_profile).await,
Ok(Err(e)) => handle_update_error(e, current_profile).await,
Err(_) => handle_timeout(current_profile).await,
Ok(outcome) if outcome.is_valid() => handle_success(current_value).await,
Ok(outcome) => handle_validation_failure(outcome, current_profile).await,
Err(e) => handle_update_error(e, current_profile).await,
}
}
@@ -312,7 +327,9 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
perform_config_update(target_profile, previous_profile.as_ref()).await
perform_config_update(target_profile, previous_profile.as_ref())
.await
.map_err(|error| coded_error("PROFILE_SWITCH_FAILED", error))
}
/// 根据profile name修改profiles
@@ -344,14 +361,24 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
false
};
profiles_patch_item_safe(&index, &profile).await.stringify_err()?;
// A selection written from the UI or the chain proxy is newer than anything a restore still
// in flight captured; without this it would be pushed back to the older node moments later.
let records_a_selection = profile.selected.is_some();
profiles_patch_item_safe(&index, &profile)
.await
.with_error_code("PROFILE_UPDATE_FAILED")?;
if records_a_selection {
profiles::supersede_selected_activation();
}
// 如果更新间隔或允许自动更新变更,异步刷新定时器
if should_refresh_timer {
crate::process::AsyncHandler::spawn(move || async move {
logging!(info, Type::Timer, "定时器更新间隔已变更,正在刷新定时器...");
logging!(info, Type::Timer, "Timer update settings changed, refreshing timer...");
if let Err(e) = crate::core::Timer::global().refresh().await {
logging!(error, Type::Timer, "刷新定时器失败: {}", e);
logging!(error, Type::Timer, "Failed to refresh timer: {}", e);
} else {
// 刷新成功后发送自定义事件,不触发配置重载
crate::core::handle::Handle::notify_timer_updated(&index);
@@ -369,17 +396,22 @@ pub async fn view_profile(index: String) -> CmdResult {
let profiles_ref = profiles.latest_arc();
let file = profiles_ref
.get_item(&index)
.stringify_err()?
.with_error_code("PROFILE_OPEN_FAILED")?
.file
.as_ref()
.ok_or("the file field is null")?;
.ok_or_else(|| coded_error("PROFILE_OPEN_FAILED", "the file field is null"))?;
let path = dirs::app_profiles_dir().stringify_err()?.join(file.as_str());
let path = dirs::app_profiles_dir()
.with_error_code("PROFILE_OPEN_FAILED")?
.join(file.as_str());
if !path.exists() {
return CmdResult::Err(format!("file not found \"{}\"", path.display()).into());
return CmdResult::Err(coded_error(
"PROFILE_OPEN_FAILED",
format!("file not found \"{}\"", path.display()),
));
}
help::open_file(path).stringify_err()
help::open_file(path).with_error_code("PROFILE_OPEN_FAILED")
}
/// 读取配置文件内容
@@ -389,11 +421,32 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
PrfItem {
file: profiles_ref.get_item(&index).stringify_err()?.file.to_owned(),
file: profiles_ref
.get_item(&index)
.with_error_code("PROFILE_READ_FAILED")?
.file
.to_owned(),
..Default::default()
}
};
let data = item.read_file().await.stringify_err()?;
if let Some(file) = item.file.as_ref() {
let path = dirs::app_profiles_dir()
.with_error_code("PROFILE_READ_FAILED")?
.join(file.as_str());
match tokio::fs::try_exists(&path).await {
Ok(true) => {}
Ok(false) => return Ok(String::new()),
Err(err) => {
return Err(coded_error(
"PROFILE_READ_FAILED",
format!("failed to check profile file \"{}\": {err}", path.display()),
));
}
}
}
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data)
}
@@ -404,3 +457,105 @@ pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time)
}
#[cfg(test)]
mod tests {
use super::{commit_current_profile, run_profile_config_update_transition};
use crate::config::{IProfiles, PrfItem};
use crate::core::validate::ValidationOutcome;
use clash_verge_draft::Draft;
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
task::Poll,
time::Duration,
};
use tokio::sync::Barrier;
struct CancellationProbe {
cancelled: Arc<AtomicBool>,
completed: Arc<AtomicBool>,
}
impl Drop for CancellationProbe {
fn drop(&mut self) {
if !self.completed.load(Ordering::Acquire) {
self.cancelled.store(true, Ordering::Release);
}
}
}
fn profile(uid: &str) -> PrfItem {
PrfItem {
uid: Some(uid.into()),
..PrfItem::default()
}
}
#[tokio::test]
async fn committing_profile_switch_preserves_profiles_added_after_draft_creation() -> anyhow::Result<()> {
let profiles = Draft::new(IProfiles {
current: Some("a".into()),
items: Some(vec![profile("a"), profile("b")]),
});
profiles.edit_draft(|draft| {
draft.patch_config(&IProfiles {
current: Some("b".into()),
items: None,
});
});
profiles
.with_data_modify(|mut committed| async move {
committed.items.get_or_insert_with(Vec::new).push(profile("new"));
Ok((committed, ()))
})
.await?;
commit_current_profile(&profiles, Some("b".into())).await?;
let committed = profiles.data_arc();
assert_eq!(committed.current.as_deref(), Some("b"));
assert!(committed.get_item("new").is_ok());
Ok(())
}
#[tokio::test(start_paused = true)]
async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> {
let update_started = Arc::new(Barrier::new(2));
let release_update = Arc::new(Barrier::new(2));
let update_cancelled = Arc::new(AtomicBool::new(false));
let update_completed = Arc::new(AtomicBool::new(false));
let mut update = Box::pin(run_profile_config_update_transition({
let update_started = Arc::clone(&update_started);
let release_update = Arc::clone(&release_update);
let update_cancelled = Arc::clone(&update_cancelled);
let update_completed = Arc::clone(&update_completed);
move || async move {
let _probe = CancellationProbe {
cancelled: update_cancelled,
completed: Arc::clone(&update_completed),
};
update_started.wait().await;
release_update.wait().await;
update_completed.store(true, Ordering::Release);
Ok(ValidationOutcome::Valid)
}
}));
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
update_started.wait().await;
tokio::time::advance(Duration::from_secs(31)).await;
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
assert!(!update_cancelled.load(Ordering::Acquire));
release_update.wait().await;
assert!(update.await?.is_valid());
assert!(update_completed.load(Ordering::Acquire));
assert!(!update_cancelled.load(Ordering::Acquire));
Ok(())
}
}
+92 -3
View File
@@ -1,12 +1,73 @@
use super::CmdResult;
use crate::core::tray::Tray;
use crate::process::AsyncHandler;
use crate::{
cmd::StringifyErr as _,
config::Config,
core::{
handle::Handle,
proxy_view::{ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_yaml_ng::Mapping;
use std::{
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
};
/// Record which node a group is on, in the current profile.
///
/// Takes the group and the node rather than the whole selection list, and merges them into the
/// profile on this side. The frontend used to send the list it had rendered, which made two
/// selections made before that list refreshed into one overwriting the other: both were built
/// from the same stale array, and the later write dropped the earlier group's choice. Since a
/// core start re-applies whatever the profile holds, the dropped one then came back on restart.
///
/// The tray already recorded this way. Now there is one way.
#[tauri::command]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
config
.and_then(|config| config.get("proxy-groups"))
.and_then(|groups| groups.as_sequence())
.into_iter()
.flatten()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.filter(|name| !name.is_empty() && *name != "GLOBAL")
.filter(|name| seen.insert((*name).to_owned()))
.map(str::to_owned)
.collect()
}
#[tauri::command]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
proxies,
providers: providers.ok(),
}))
}
/// 同步托盘和GUI的代理选择状态
#[tauri::command]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
@@ -50,3 +111,31 @@ async fn run_tray_sync_loop() {
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use serde_yaml_ng::Value;
use super::runtime_group_order;
#[test]
fn runtime_order_keeps_first_non_empty_non_global_name() {
let config: Value = serde_yaml_ng::from_str(
r#"
proxy-groups:
- name: Beta
- name: ""
- name: GLOBAL
- name: " Alpha "
- name: Beta
"#,
)
.expect("parse runtime");
assert_eq!(
runtime_group_order(config.as_mapping()),
["Beta".to_owned(), " Alpha ".to_owned()]
);
}
}
+6 -8
View File
@@ -1,5 +1,5 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager};
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
use anyhow::{Context as _, anyhow};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
@@ -22,7 +22,7 @@ pub async fn get_runtime_yaml() -> CmdResult<String> {
config
.ok_or_else(|| anyhow!("failed to parse config to yaml file"))
.and_then(|config| {
serde_yaml_ng::to_string(config)
yaml_emitter::to_mihomo_config_string(config)
.context("failed to convert config to yaml")
.map(|s| s.into())
})
@@ -82,7 +82,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
config.insert("proxies".into(), proxies_chain);
serde_yaml_ng::to_string(&config)
yaml_emitter::to_mihomo_config_string(&config)
.context("YAML generation failed")
.map(|s| s.into())
.stringify_err()
@@ -94,12 +94,10 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
/// 更新运行时链式代理配置
#[tauri::command]
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> {
match CoreManager::global()
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
.await
{
let runtime = Config::runtime().await;
runtime.edit_draft(|d| d.update_proxy_chain_config(proxy_chain_config));
// 我们需要在 CoreManager 中验证并应用配置,这里不应该直接调用 runtime.apply()
}
match CoreManager::global().apply_generate_config().await {
Ok(outcome) if outcome.is_valid() => {}
Ok(outcome) => logging!(
warn,
+35 -15
View File
@@ -40,19 +40,29 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
(path, is_merge, is_script, affects_runtime)
};
// 读取原始内容(在释放profiles_guard后进行)
let original_content = PrfItem {
file: Some(rel_path.clone()),
..Default::default()
}
.read_file()
.await
.stringify_err()?;
let profiles_dir = dirs::app_profiles_dir().stringify_err()?;
let file_path = profiles_dir.join(rel_path.as_str());
let file_path_str = file_path.to_string_lossy().to_string();
// 读取原始内容(在释放profiles_guard后进行)
let original_existed = fs::try_exists(&file_path).await.map_err(|err| {
String::from(format!(
"failed to check profile file \"{}\": {err}",
file_path.display()
))
})?;
let original_content = if original_existed {
PrfItem {
file: Some(rel_path.clone()),
..Default::default()
}
.read_file()
.await
.stringify_err()?
} else {
String::new()
};
// 保存新的配置文件
fs::write(&file_path, &file_data).await.stringify_err()?;
@@ -68,6 +78,7 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
&file_path_str,
&file_path,
&original_content,
original_existed,
is_merge_file,
is_script_file,
affects_runtime,
@@ -83,8 +94,16 @@ pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdR
Ok(changes_applied)
}
async fn restore_original(file_path: &std::path::Path, original_content: &str) -> Result<(), String> {
fs::write(file_path, original_content).await.stringify_err()
async fn restore_original(
file_path: &std::path::Path,
original_content: &str,
original_existed: bool,
) -> Result<(), String> {
if original_existed {
fs::write(file_path, original_content).await.stringify_err()
} else {
fs::remove_file(file_path).await.stringify_err()
}
}
fn profile_affects_runtime(profiles: &IProfiles, index: &str) -> bool {
@@ -112,6 +131,7 @@ async fn handle_saved_profile_file(
file_path_str: &str,
file_path: &std::path::Path,
original_content: &str,
original_existed: bool,
is_merge_file: bool,
is_script_file: bool,
affects_runtime: bool,
@@ -138,13 +158,13 @@ async fn handle_saved_profile_file(
}
Ok(outcome) => {
logging!(warn, Type::Config, "[cmd配置save] 文件验证失败: {}", outcome);
restore_original(file_path, original_content).await?;
restore_original(file_path, original_content, original_existed).await?;
handle_validation_notice(&outcome, target, file_type);
return Ok(outcome);
}
Err(e) => {
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {}", e);
restore_original(file_path, original_content).await?;
restore_original(file_path, original_content, original_existed).await?;
return Err(e.to_string().into());
}
}
@@ -165,13 +185,13 @@ async fn handle_saved_profile_file(
}
Ok(outcome) => {
logging!(warn, Type::Config, "[cmd配置save] 运行时配置应用失败: {}", outcome);
restore_original(file_path, original_content).await?;
restore_original(file_path, original_content, original_existed).await?;
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
Ok(outcome)
}
Err(err) => {
logging!(error, Type::Config, "[cmd配置save] 运行时配置应用错误: {}", err);
restore_original(file_path, original_content).await?;
restore_original(file_path, original_content, original_existed).await?;
Err(err.to_string().into())
}
}
+29 -15
View File
@@ -1,37 +1,51 @@
use super::{CmdResult, StringifyErr as _};
use crate::core::service::{self, SERVICE_MANAGER, ServiceStatus};
use smartstring::SmartString;
use super::{CmdResult, WithErrorCode as _};
use crate::core::{
CoreManager,
service::{SERVICE_MANAGER, ServiceStatus},
};
async fn execute_service_operation_sync(status: ServiceStatus, op_type: &str) -> CmdResult {
if let Err(e) = SERVICE_MANAGER.lock().await.handle_service_status(&status).await {
let emsg = format!("{} Service failed: {}", op_type, e);
return Err(SmartString::from(emsg));
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager.controlled_stop_core_inner().await.with_error_code(error_code)?;
}
Ok(())
SERVICE_MANAGER
.handle_service_status(status)
.await
.with_error_code(error_code)
}
#[tauri::command]
pub async fn install_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::InstallRequired, "Install").await
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
#[tauri::command]
pub async fn uninstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::UninstallRequired, "Uninstall").await
CoreManager::global()
.uninstall_service_and_start_sidecar()
.await
.with_error_code("SERVICE_UNINSTALL_FAILED")
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "Reinstall").await
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "Repair").await
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
#[tauri::command]
pub async fn is_service_available() -> CmdResult<bool> {
service::is_service_available().await.stringify_err()?;
Ok(true)
pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar()
.await
.with_error_code("SERVICE_SIDECAR_FAILED")
}
+8 -6
View File
@@ -1,9 +1,11 @@
use std::sync::Arc;
use crate::core::runstate::{RUN_STATE, RunStateView};
use crate::core::{CoreManager, manager::RunningMode};
/// 获取当前内核运行模式
/// The whole Run State in one call.
///
/// Replaces the separate running-mode and service-install-state commands: they were three
/// caches on three refresh intervals describing one fact, which the frontend then had to keep
/// coherent by hand.
#[tauri::command]
pub async fn get_running_mode() -> Result<Arc<RunningMode>, String> {
Ok(CoreManager::global().get_running_mode())
pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view())
}
+18 -5
View File
@@ -1,12 +1,13 @@
use crate::config::Config;
use crate::constants::{network, tun as tun_const};
use crate::utils::dirs::{ipc_path, path_to_str};
use crate::utils::dirs::{path_to_str, sidecar_ipc_path};
use crate::utils::{dirs, help};
use anyhow::Result;
use clash_verge_logging::{Type, logging};
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use std::{
borrow::Cow,
net::{IpAddr, Ipv4Addr, SocketAddr},
str::FromStr as _,
};
@@ -179,6 +180,17 @@ impl IClashTemp {
}),
}
}
/// 容错读取当前代理模式。
///
/// 仅从已保存的 clash 配置 Mapping 中提取 `mode` 字段,不依赖 mihomo `/configs`
/// 的严格 `BaseConfig` 反序列化,因此即使核心返回的字段与插件结构体不匹配也能取到。
pub fn get_mode(&self) -> Option<String> {
self.0.get("mode").and_then(|value| match value {
Value::String(val_str) => Some(val_str.clone()),
_ => None,
})
}
#[cfg(not(target_os = "windows"))]
pub fn guard_redir_port(config: &Mapping) -> u16 {
let mut port = config
@@ -267,11 +279,11 @@ impl IClashTemp {
let val_str = val_str.trim();
let val = match val_str.starts_with(':') {
true => format!("127.0.0.1{val_str}"),
false => val_str.to_owned(),
true => Cow::Owned(format!("127.0.0.1{val_str}")),
false => Cow::Borrowed(val_str),
};
SocketAddr::from_str(val.as_str()).ok().map(|s| s.to_string())
SocketAddr::from_str(&val).ok().map(|s| s.to_string())
}
None => None,
})
@@ -314,7 +326,7 @@ impl IClashTemp {
pub fn guard_external_controller_ipc() -> String {
// 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致
ipc_path()
sidecar_ipc_path()
.ok()
.and_then(|path| path_to_str(&path).ok().map(|s| s.into()))
.unwrap_or_else(|| {
@@ -421,6 +433,7 @@ pub struct IClashDNS {
pub default_nameserver: Option<Vec<String>>,
pub enhanced_mode: Option<String>,
pub fake_ip_range: Option<String>,
pub fake_ip_range6: Option<String>,
pub use_hosts: Option<bool>,
pub fake_ip_filter: Option<Vec<String>>,
pub nameserver: Option<Vec<String>>,
+103 -22
View File
@@ -1,11 +1,11 @@
use super::{IClashTemp, IProfiles, IVerge};
use crate::{
config::{PrfItem, profiles_append_item_safe, runtime::IRuntime},
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
constants::{files, timing},
core::{
CoreManager,
handle::{self, Handle},
service, tray,
tray,
validate::CoreConfigValidator,
},
enhance,
@@ -18,8 +18,11 @@ use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf};
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use std::{
collections::HashSet,
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
};
use tokio::sync::OnceCell;
use tokio::time::sleep;
@@ -30,6 +33,8 @@ pub struct Config {
runtime_config: Draft<IRuntime>,
}
static TUN_SESSION_SUPPRESSED: AtomicBool = AtomicBool::new(false);
impl Config {
pub async fn global() -> &'static Self {
static CONFIG: OnceCell<Config> = OnceCell::const_new();
@@ -63,35 +68,69 @@ impl Config {
/// 初始化订阅
pub async fn init_config() -> Result<()> {
Self::init_config_before_window().await?;
Self::init_runtime_config().await
}
pub async fn init_config_before_window() -> Result<()> {
Self::ensure_default_profile_items().await?;
let verge = Self::verge().await.latest_arc();
clash_verge_i18n::sync_locale(verge.language.as_deref());
// init Tun mode
let handle = Handle::app_handle();
let is_admin = is_current_app_handle_admin(handle);
let is_service_available = service::is_service_available().await.is_ok();
if !is_admin && !is_service_available {
let verge = Self::verge().await;
verge.edit_draft(|d| {
d.enable_tun_mode = Some(false);
});
verge.apply();
let _ = tray::Tray::global().update_menu().await;
Ok(())
}
// 分离数据获取和异步调用避免Send问题
let verge_data = Self::verge().await.latest_arc();
logging_error!(Type::Core, verge_data.save_file().await);
}
pub fn tun_suppressed_for_session() -> bool {
TUN_SESSION_SUPPRESSED.load(Ordering::Acquire)
}
let validation_result = Self::generate_and_validate().await?;
pub(crate) async fn suppress_tun_for_session() {
TUN_SESSION_SUPPRESSED.store(true, Ordering::Release);
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
}
pub(crate) async fn restore_tun_for_session() {
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
}
pub(crate) async fn disable_tun_and_persist() -> Result<()> {
TUN_SESSION_SUPPRESSED.store(false, Ordering::Release);
let verge = Self::verge().await;
verge.edit_draft(|draft| {
draft.enable_tun_mode = Some(false);
});
verge.apply();
verge.data_arc().save_file().await?;
Handle::refresh_verge();
let _ = tray::Tray::global().update_menu().await;
Ok(())
}
pub async fn init_runtime_config() -> Result<()> {
let fallback_applied = match Self::resolve_startup_mixed_port().await {
Ok(applied) => applied,
Err(error) => {
Self::block_startup_core(&error);
return Err(error);
}
};
let validation_result = if fallback_applied {
None
} else {
Self::generate_and_validate().await?
};
if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await;
handle::Handle::notice_message(msg_type, msg_content);
}
Self::runtime().await.apply();
{
let profiles = Self::profiles().await.data_arc();
// Logging error internally
@@ -104,13 +143,26 @@ impl Config {
// Ensure "Merge" and "Script" profile items exist, adding them if missing.
async fn ensure_default_profile_items() -> Result<()> {
let profiles = Self::profiles().await;
Self::ensure_default_profile_items_for(&profiles).await
}
async fn ensure_default_profile_items_for(profiles: &Draft<IProfiles>) -> Result<()> {
if profiles.latest_arc().get_items().is_none() {
logging!(
warn,
Type::Config,
"Profile items 无法加载,跳过默认项初始化以保留现有配置文件"
);
return Ok(());
}
if profiles.latest_arc().get_item("Merge").is_err() {
let merge_item = &mut PrfItem::from_merge(Some("Merge".into()))?;
profiles_append_item_safe(merge_item).await?;
profiles_append_item_to_safe(profiles, merge_item).await?;
}
if profiles.latest_arc().get_item("Script").is_err() {
let script_item = &mut PrfItem::from_script(Some("Script".into()))?;
profiles_append_item_safe(script_item).await?;
profiles_append_item_to_safe(profiles, script_item).await?;
}
Ok(())
}
@@ -208,6 +260,10 @@ impl Config {
}
pub async fn verify_config_initialization() {
if Self::startup_core_block_reason().is_some() {
return;
}
let backoff = ExponentialBuilder::default()
.with_min_delay(std::time::Duration::from_millis(100))
.with_max_delay(std::time::Duration::from_secs(2))
@@ -360,4 +416,29 @@ mod tests {
let box_iruntime_size = std::mem::size_of_val(&draft);
assert_eq!(box_iruntime_size, std::mem::size_of::<Draft<Box<IRuntime>>>());
}
#[tokio::test]
async fn failed_profile_index_survives_startup_without_cleanup() -> Result<()> {
let profiles = Draft::new(IProfiles::default());
let profiles_dir = std::env::temp_dir().join(format!("clash-verge-profile-cleanup-{}", nanoid::nanoid!()));
tokio::fs::create_dir_all(&profiles_dir).await?;
let active_profile = profiles_dir.join("Ractive.yaml");
tokio::fs::write(&active_profile, "proxies: []").await?;
Config::ensure_default_profile_items_for(&profiles).await?;
profiles.data_arc().cleanup_orphaned_files_in(&profiles_dir).await?;
let profile_was_preserved = tokio::fs::try_exists(&active_profile).await?;
tokio::fs::remove_dir_all(&profiles_dir).await?;
assert!(
profile_was_preserved,
"startup must not delete profiles when profiles.yaml could not be loaded"
);
assert!(
profiles.data_arc().get_items().is_none(),
"startup must not replace an unreadable profile index with defaults"
);
Ok(())
}
}
+7 -10
View File
@@ -1,6 +1,6 @@
use crate::utils::dirs::get_encryption_key;
use aes_gcm::{
Aes256Gcm, Key,
Aes256Gcm,
aead::{Aead as _, KeyInit as _},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -16,33 +16,29 @@ tokio::task_local! {
}
/// Encrypt data
#[allow(deprecated)]
pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
let encryption_key = get_encryption_key()?;
let key = Key::<Aes256Gcm>::from_slice(&encryption_key);
let cipher = Aes256Gcm::new(key);
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
// Generate random nonce
let mut nonce = vec![0u8; NONCE_LENGTH];
let mut nonce = [0u8; NONCE_LENGTH];
getrandom::fill(&mut nonce)?;
// Encrypt data
let ciphertext = cipher
.encrypt(nonce.as_slice().into(), data.as_bytes())
.encrypt((&nonce).into(), data.as_bytes())
.map_err(|e| format!("Encryption failed: {e}"))?;
// Concatenate nonce and ciphertext and encode them in base64
let mut combined = nonce;
let mut combined = nonce.to_vec();
combined.extend(ciphertext);
Ok(STANDARD.encode(combined))
}
/// Decrypt data
#[allow(deprecated)]
pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> {
let encryption_key = get_encryption_key()?;
let key = Key::<Aes256Gcm>::from_slice(&encryption_key);
let cipher = Aes256Gcm::new(key);
let cipher = Aes256Gcm::new_from_slice(&encryption_key)?;
// Decode from base64
let data = STANDARD.decode(encrypted)?;
if data.len() < NONCE_LENGTH {
@@ -51,6 +47,7 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
// Separate nonce and ciphertext
let (nonce, ciphertext) = data.split_at(NONCE_LENGTH);
let nonce: &[u8; NONCE_LENGTH] = nonce.try_into()?;
// Decrypt data
let plaintext = cipher
+114
View File
@@ -0,0 +1,114 @@
//! Resolving the Mixed Port.
//!
//! Two different questions were being asked with one answer:
//!
//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that
//! writes configuration or decides what to start needs this, and it has to be answerable
//! before the Core exists.
//! - *What is the Core actually serving on?* — what the Core reports, else what we configured.
//! Anything that points a client at the proxy wants this, because the two can differ: a
//! startup fallback moves the port, and a hand-edited Core config can move it too.
//!
//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config
//! entirely) and the frontend hand-copied a version of the second.
use anyhow::Result;
use super::Config;
use crate::core::handle::Handle;
/// The port mihomo listens on when nothing else is configured.
pub const DEFAULT_MIXED_PORT: u16 = 7897;
/// Resolving the Mixed Port.
pub struct MixedPort;
impl MixedPort {
/// The Mixed Port the app has configured.
///
/// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe
/// to call before the Core is running, unlike [`Self::effective`].
///
/// Reading the draft means a caller that hands this to something *outside* the app — the
/// PAC endpoint is the one that does — can name a port the Core has not moved to yet.
/// What makes that safe is that the only path staging a listener port,
/// `feat::listener::save_proxy_ports`, closes PAC across the staging with `core_starting`
/// and reopens it with `core_start_settled`. A second path that stages a listener port
/// while the Core is serving would have to do the same.
pub async fn desired() -> u16 {
let selected = Config::verge().await.latest_arc().verge_mixed_port;
// `get_mixed_port` already falls back to the default when the Merge Config is silent.
let merged = Config::clash().await.latest_arc().get_mixed_port();
resolve_desired(selected, merged)
}
/// The Mixed Port the Core is actually serving on.
///
/// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on
/// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked,
/// which is the right answer whenever the Core is not running anyway.
pub async fn effective() -> u16 {
let desired = Self::desired().await;
resolve_effective(
|| async { Ok(Handle::mihomo().get_base_config().await?.mixed_port) },
desired,
)
.await
}
}
/// Prefer what the user selected, else what the Merge Config resolved to.
const fn resolve_desired(selected: Option<u16>, merged: u16) -> u16 {
match selected {
Some(port) => port,
None => merged,
}
}
/// Prefer what the Core reports; fall back to what we configured.
async fn resolve_effective<Live, LiveFuture>(live: Live, desired: u16) -> u16
where
Live: FnOnce() -> LiveFuture,
LiveFuture: Future<Output = Result<u16>>,
{
match live().await {
// A Core reporting port 0 is a Core that has not bound anything yet.
Ok(port) if port != 0 => port,
_ => desired,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
use super::*;
#[test]
fn a_selected_port_wins_over_the_merge_config() {
assert_eq!(resolve_desired(Some(9000), 7897), 9000);
}
#[test]
fn the_merge_config_answers_when_nothing_is_selected() {
assert_eq!(resolve_desired(None, 8080), 8080);
}
#[tokio::test]
async fn a_reporting_core_overrides_what_we_configured() {
// This is the startup-fallback case: we asked for 7897 and the Core landed on 7898.
assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898);
}
#[tokio::test]
async fn an_unreachable_core_leaves_the_configured_port_standing() {
assert_eq!(
resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await,
7897
);
}
#[tokio::test]
async fn a_core_that_has_not_bound_yet_is_not_believed() {
assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897);
}
}
+4 -1
View File
@@ -2,12 +2,15 @@ mod clash;
#[allow(clippy::module_inception)]
mod config;
mod encrypt;
mod mixed_port;
mod port;
mod prfitem;
pub mod profiles;
pub mod runtime;
pub(crate) mod snapshot;
mod verge;
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
pub use self::{clash::*, config::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
+333
View File
@@ -0,0 +1,333 @@
use super::{
Config, ConfigType, IClashTemp, IVerge,
snapshot::{capture_config_files, restore_files},
};
use crate::{
constants::timing,
core::{
handle::Handle,
listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys},
owner_identity::current_owner_credentials,
service::{SERVICE_MANAGER, ServiceStatus},
validate::CoreConfigValidator,
},
process::AsyncHandler,
utils::port::find_next_available_port,
};
use anyhow::{Context as _, Result, anyhow, bail};
use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_yaml_ng::Value;
use std::{
collections::HashSet,
net::SocketAddr,
str::FromStr as _,
sync::atomic::{AtomicBool, Ordering},
};
#[derive(Clone, Copy)]
struct MixedPortFallback {
original: u16,
current: u16,
}
static PENDING_FALLBACK_NOTICE: Lazy<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
pub(crate) async fn retry_startup_mixed_port_fallback() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
async fn resolve_startup_mixed_port_inner() -> Result<bool> {
let clash = Self::clash().await.latest_arc();
let verge = Self::verge().await.latest_arc();
let selected_port = clash.get_mixed_port();
let bind_scope =
ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?;
if owned_service_core_uses_port(selected_port).await {
logging!(
info,
Type::Setup,
"Mixed proxy port {} belongs to the current user's managed core; skipping fallback",
selected_port
);
return Ok(false);
}
let selected_scope = bind_scope.clone();
let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port))
.await
.context("mixed proxy port probe task failed")?;
if !port_in_use {
return Ok(false);
}
let reserved = configured_listener_ports(&clash, &verge);
let candidate = AsyncHandler::spawn_blocking(move || {
find_next_available_port(selected_port, &reserved, |port| {
bind_scope.mixed_port_is_available(port)
})
})
.await
.context("mixed proxy fallback scan task failed")?
.ok_or_else(|| anyhow!("no eligible mixed proxy port is available"))?;
Self::apply_startup_mixed_port_fallback(selected_port, candidate).await?;
Ok(true)
}
async fn apply_startup_mixed_port_fallback(old_port: u16, new_port: u16) -> Result<()> {
let clash = Self::clash().await;
let verge = Self::verge().await;
let runtime = Self::runtime().await;
// Every failure below leaves the three layers as they were, without saying so.
let transaction = DraftTransaction::begin(vec![&clash, &verge, &runtime])?;
clash.edit_draft(|draft| {
draft.0.insert(MIXED_PORT_KEY.into(), new_port.into());
});
verge.edit_draft(|draft| {
draft.verge_mixed_port = Some(new_port);
});
Self::generate()
.await
.context("failed to materialize runtime configuration with fallback port")?;
let validation = CoreConfigValidator::global()
.validate_config_outcome()
.await
.context("failed to validate runtime configuration with fallback port")?;
if !validation.is_valid() {
bail!("runtime configuration with fallback port is invalid: {validation}");
}
let snapshots = capture_config_files().await?;
let candidate_clash = clash.latest_arc();
let candidate_verge = verge.latest_arc();
let persist_result = async {
candidate_clash
.save_config()
.await
.context("failed to persist Application Merge Configuration")?;
candidate_verge
.save_file()
.await
.context("failed to persist selected mixed proxy port")?;
Self::generate_file(ConfigType::Run)
.await
.context("failed to persist Runtime Configuration")?;
Ok::<(), anyhow::Error>(())
}
.await;
// Files are not part of the transaction, so a failed write is restored explicitly.
// The drafts are rolled back first: restoring reads the committed configuration, and
// must not see a candidate that is being abandoned.
if let Err(error) = persist_result {
transaction.rollback();
return match restore_files(&snapshots).await {
Ok(()) => Err(error),
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
};
}
transaction.commit();
record_fallback(old_port, new_port);
Handle::refresh_clash();
Handle::refresh_verge();
logging!(
warn,
Type::Config,
"Mixed proxy port {} is externally occupied; switched to {} and persisted all configuration layers",
old_port,
new_port
);
Ok(())
}
pub(crate) fn block_startup_core(error: &anyhow::Error) {
STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string());
}
pub(crate) fn startup_core_block_reason() -> Option<String> {
if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
STARTUP_CORE_BLOCK_REASON.lock().clone()
} else {
None
}
}
pub(crate) fn notify_startup_mixed_port_fallback() {
let Some(change) = PENDING_FALLBACK_NOTICE.lock().take() else {
return;
};
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message(
"mixed_port::fallback",
format!("{},{}", change.original, change.current),
);
});
}
}
fn record_fallback(old_port: u16, new_port: u16) {
let mut pending = PENDING_FALLBACK_NOTICE.lock();
match pending.as_mut() {
Some(change) => change.current = new_port,
None => {
*pending = Some(MixedPortFallback {
original: old_port,
current: new_port,
});
}
}
}
fn report_fallback_error(message: String) {
logging!(
error,
Type::Config,
"Automatic mixed proxy port fallback failed: {}",
message
);
AsyncHandler::spawn(move || async move {
tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
Handle::notice_message("mixed_port::fallback_error", message);
});
}
// Only service-managed cores can survive into this startup phase; this app has not spawned its sidecar yet.
async fn owned_service_core_uses_port(port: u16) -> bool {
if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
return false;
}
let credentials = match current_owner_credentials() {
Ok(credentials) => credentials,
Err(error) => {
logging!(
warn,
Type::Service,
"Unable to identify current service owner while checking mixed proxy port: {error:#}"
);
return false;
}
};
let response = match clash_verge_service_ipc::get_status(&credentials).await {
Ok(response) => response,
Err(error) => {
logging!(
warn,
Type::Service,
"Unable to query current service owner while checking mixed proxy port: {error:#}"
);
return false;
}
};
let Some(status) = response.data else {
return false;
};
if response.code > 0 || !status.is_active || status.core_pid.is_none() {
return false;
}
match Handle::mihomo().get_base_config().await {
Ok(config) => config.mixed_port == port,
Err(error) => {
logging!(
warn,
Type::Service,
"Current user's service core is active but its mixed proxy port is unavailable: {error}; \
preserving the selected port until core replacement resolves ownership"
);
true
}
}
}
fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet<u16> {
let mut ports = HashSet::new();
// Every listener except the Mixed Port itself: that is the one being reassigned, so
// colliding with its current value is exactly what we are trying to do.
for key in proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY) {
if let Some(port) = mapping_port(&clash.0, key) {
ports.insert(port);
}
}
if let Ok(controller) = SocketAddr::from_str(IClashTemp::guard_external_controller(&clash.0).as_str()) {
ports.insert(controller.port());
}
ports.extend([verge.verge_socks_port, verge.verge_port].into_iter().flatten());
#[cfg(not(target_os = "windows"))]
if let Some(port) = verge.verge_redir_port {
ports.insert(port);
}
#[cfg(target_os = "linux")]
if let Some(port) = verge.verge_tproxy_port {
ports.insert(port);
}
ports
}
fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option<u16> {
mapping.get(key).and_then(|value| match value {
Value::String(port) => port.parse().ok(),
Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
_ => None,
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod listener_key_tests {
use super::{MIXED_PORT_KEY, proxy_listener_keys};
#[test]
fn the_reserved_set_covers_every_listener_but_the_one_being_moved() {
let reserved: Vec<_> = proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY).collect();
assert!(
!reserved.contains(&MIXED_PORT_KEY),
"the port being reassigned must not reserve itself"
);
assert_eq!(
reserved.len(),
proxy_listener_keys().count() - 1,
"every other listener must be reserved, so a new one cannot be forgotten here"
);
}
}
#[cfg(test)]
mod tests {
use super::configured_listener_ports;
use crate::config::{IClashTemp, IVerge};
#[test]
fn configured_ports_include_disabled_listener_assignments() {
let ports = configured_listener_ports(&IClashTemp::template(), &IVerge::template());
assert!(ports.contains(&7898));
assert!(ports.contains(&7899));
assert!(ports.contains(&9097));
#[cfg(not(target_os = "windows"))]
assert!(ports.contains(&7895));
#[cfg(target_os = "linux")]
assert!(ports.contains(&7896));
}
}
+26 -4
View File
@@ -63,7 +63,7 @@ pub struct PrfItem {
pub file_data: Option<String>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PrfSelected {
pub name: Option<String>,
pub now: Option<String>,
@@ -262,7 +262,7 @@ impl PrfItem {
let with_proxy = option.is_some_and(|o| o.with_proxy.unwrap_or(false));
let self_proxy = option.is_some_and(|o| o.self_proxy.unwrap_or(false));
let accept_invalid_certs = option.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false));
let allow_auto_update = option.map(|o| o.allow_auto_update.unwrap_or(true));
let allow_auto_update = Some(allow_auto_update_enabled(option));
let user_agent = option.and_then(|o| o.user_agent.clone());
let update_interval = option.and_then(|o| o.update_interval);
let timeout = option.and_then(|o| o.timeout_seconds).unwrap_or(20);
@@ -297,7 +297,7 @@ impl PrfItem {
Ok(r) => r,
Err(e) => {
tokio::time::sleep(Duration::from_millis(100)).await;
bail!("failed to fetch remote profile: {}", e);
return Err(e).context("failed to fetch remote profile");
}
};
@@ -533,7 +533,9 @@ impl PrfItem {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("could not find the file"))?;
let path = dirs::app_profiles_dir()?.join(file.as_str());
let content = fs::read_to_string(path).await.context("failed to read the file")?;
let content = fs::read_to_string(&path)
.await
.with_context(|| format!("failed to read the file \"{}\"", path.display()))?;
Ok(content.into())
}
@@ -583,6 +585,10 @@ const fn default_allow_auto_update() -> Option<bool> {
Some(true)
}
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
option.and_then(|o| o.allow_auto_update).unwrap_or(true)
}
/// Fix URLs where query parameters are incorrectly appended to the path segment
///
/// Incorrect Example: https://example.com/path&param1=value1
@@ -611,3 +617,19 @@ fn fix_dirty_url(input: &str) -> Result<Url> {
Ok(url)
}
#[cfg(test)]
mod tests {
use super::{PrfOption, allow_auto_update_enabled};
#[test]
fn auto_update_defaults_to_enabled_and_preserves_explicit_false() {
assert!(allow_auto_update_enabled(None));
let disabled = PrfOption {
allow_auto_update: Some(false),
..PrfOption::default()
};
assert!(!allow_auto_update_enabled(Some(&disabled)));
}
}
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -37,23 +37,22 @@ impl IRuntime {
}
}
let patch_tun = patch.get("tun");
if let Some(patch_tun_value) = patch_tun {
let mut tun = config
.get("tun")
.and_then(|val| val.as_mapping())
.cloned()
.unwrap_or_else(Mapping::new);
let Some(patch_tun) = patch.get("tun") else {
return;
};
if let Some(patch_tun_mapping) = patch_tun_value.as_mapping() {
for key in use_keys(patch_tun_mapping) {
if let Some(value) = patch_tun_mapping.get(key.as_str()) {
tun.insert(Value::from(key.as_str()), value.clone());
}
let tun_key = Value::from("tun");
if !matches!(config.get(&tun_key), Some(Value::Mapping(_))) {
config.insert(tun_key.clone(), Value::Mapping(Mapping::new()));
}
if let (Some(patch_tun_mapping), Some(Value::Mapping(tun))) = (patch_tun.as_mapping(), config.get_mut(&tun_key))
{
for key in use_keys(patch_tun_mapping) {
if let Some(value) = patch_tun_mapping.get(key.as_str()) {
tun.insert(Value::from(key.as_str()), value.clone());
}
}
config.insert("tun".into(), Value::from(tun));
}
}

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