Compare commits

...
Author SHA1 Message Date
Tunglies bcacd5e0e6 test(dev): retain focused React regression assertions 2026-11-03 20:53:58 +08:00
Tunglies d8cce2ee2c chore(dev): add React Doctor audit command 2026-11-03 19:36:00 +08:00
Tunglies e69c8aeccf chore(dev): add opt-in render cause diagnostics 2026-11-03 19:29:43 +08:00
Tunglies d2c6eafc9e chore(dev): add React profiling and focused A/B workflows 2026-11-03 19:24:31 +08:00
Tunglies 2b7b7cc0e7 fix(updater): refresh changed update offers before installation 2026-11-03 18:57:05 +08:00
Tunglies 40fd1d5012 refactor: frontend cleanup (#8447)
* docs(agents): add frontend liveness constraints

Add six liveness rules to the frontend guardrails covering dead state,
unused exposed surface, unreachable fallback paths, duplicate side
effects per interaction, unreachable mounts and unbounded resource
lifecycles. These target defect classes that the existing CI gates
(knip, eslint, arch:check) cannot detect mechanically.

* refactor(frontend): remove dead states, refs and unreachable branches

Drop state that can only hold its initial value (profiles page disabled
flag, traffic graph currentFPS), a write-only window controls ref with
its unconsumed imperative handle, a no-op appendData ref method, an
unreachable gradient branch, an invisible fadeIn shell, constant-true
subexpressions in the system info card, an empty click handler and a
pointless useMemo.

* refactor(frontend): drop unused params, props and api surface

Remove the never-read _revalidate argument of mutateVerge and its 16
call-site placeholders, seven never-passed props of the split chip
editor, the unconsumed refreshDecorated context member, the now-orphaned
toggleFullscreen control, the unused translate argument of the notice
message handler, the reserved-but-unset ServiceConfig.timeout field and
unreachable branches in parseNoticeExtras.

* refactor(types): remove dead provider view types superseded by bindings

Drop the handwritten IProxyProviderItem and IRuleProviderItem
interfaces: IProxyProviderItem is a stale duplicate of the generated
ProxyProviderView contract, and neither has any references left in
the tree.

Keep IProxyItem and IProxyGroupItem: IConfigData still declares
'proxy-groups'?: IProxyGroupItem[], which builds on IProxyItem.

* fix(rules): refresh once per visibility trigger

The mount effect fired refreshRules and refreshRuleProviders
unconditionally and again under the page-visible flag, double-firing
both queries on mount and on every hide-to-show transition. Gate the
refresh on visibility once so each trigger performs exactly one
revalidation.

* fix(setting): write auto launch patch once per toggle

GuardState applies the optimistic onChange write before onGuard runs,
so repeating the same onChangeData patch inside the guard handler
duplicated every cache write and subscriber notification for the auto
launch switch.

* fix(setting): build viewer form defaults from a single source

sysproxy, misc and tunnels viewers duplicated their form default
literals in useState and open(), and the two copies in misc-viewer had
already diverged (app log max size, app log max count, auto log clean).
Extract a buildInitialValues factory per viewer and unify on the open()
values, which are the only ones users could ever see.

* fix(setting): mount only reachable proxy control viewers

ProxyControlSwitches unconditionally mounted both SysproxyViewer and
TunViewer while its switch rows are mutually exclusive, so every
instance carried a dialog that could never open, each firing
getEmbeddedServerPort or a clash query subscription on mount. Mount
each viewer under the same condition as the switch row that opens it.

SettingSystem also mounted its own SysproxyViewer and TunViewer whose
refs were never opened there; the reachable entries are the
ProxyControlSwitches rows, so the dead pair is removed. Viewer mounts
across the app drop from eight (setting-system: two own + two per
instance, proxy-tun-card: two) to three (one per rendered switch
variant).

* fix(delay): write pending state once per node in batch checks

checkListDelay pre-marked every node as checking, help() re-marked it
when picked up and measureDelay marked it a third time on entry, so
each batch run queued three identical pending updates per node. Keep
the pre-mark (immediate feedback for queued nodes) and drop the
redundant marks along the chain.

* fix(services): make retries viable and align polling freshness window

Share no abort signal across retry attempts: after a timeout abort the
old signal stays dead and every later fetch rejects immediately, so
retries only burned backoff time. Also drop the system state query's
staleTime: the wrapper maps it to SWR's dedupingInterval, which
swallowed every other 3s poll tick while it exceeded the interval.

* refactor(traffic): drop unreachable inline monitor fallback

The inline traffic monitor duplicated the worker's message loop,
snapshot throttling and aging logic but only runs when Worker is
undefined or its constructor throws, which no supported Tauri webview
or browser hits. Keep the worker as the single implementation and
remove the mode switch and the pending-message queue that could only
ever buffer the init message.

* refactor(shared): replace the traffic error boundary with ErrorBoundary

TrafficErrorBoundary maintained a second boundary system beside
BaseErrorBoundary: a report builder that only console.logged constants
in the Tauri webview, an onError that re-logged what componentDidCatch
already logged, and a fallback wrapper with a single consumer. Fold
both mount points into react-error-boundary (the primitive behind
BaseErrorBoundary) with local fallbacks that keep the panel styling,
message text and retry reset, log each error once, and drop the retry
attempt cap and the stack-details collapse along with it.

* refactor(hooks): drop the write-only error channel and key-rotating refresh

The mihomo ws subscription's next callback accepted an error half that
baseNext silently discarded while traffic, memory and log consumers
kept constructing fallback payloads for it. Refresh minted a fresh
SWR key per call via a localStorage timestamp, abandoning old keys in
the cache forever. Reduce next to its data half and reset the snapshot
under a stable key instead.

* refactor(hooks): trim dead surface from proxy selection

Drop the connection-cleanup option nobody ever disabled (behavior now
unconditional), the returned changeProxy no consumer called and the two
one-caller argument adapters; both call sites use the shared select
entry directly.

* refactor(services): drop the bespoke retry engine from the query wrapper

The custom onErrorRetry existed only to honor a function-form
retryDelay, which exactly one caller (the app data provider) used for
exponential backoff. Fall back to SWR's built-in error retry and
express that caller's cadence with constant options.
2026-11-03 10:48:45 +00:00
Tunglies 8039feb270 fix: backend lock safety (#8439)
* fix(backup): serialize restore with config write lock

Acquire the configuration write lock at the entry of
finalize_restored_verge_config so the clash, profiles and verge draft
applies all happen under it, instead of only the verge layer. A
concurrent patch (patch_clash / patch_verge via try_lock_config_write)
could otherwise stage a draft that the unlocked restore applies
overwrite, or interleave its persistence with the restore.

Also drop the pointless clones in the clash and profiles edit_draft
closures; edit_draft takes FnOnce so the restored values can be moved
in. The lock is released before patch_verge, which re-acquires it via
try_lock_config_write.

* perf(backup): extract archive off the runtime

Run File::open, ZipArchive::new and the extraction of the backup
archive inside spawn_blocking. Extracting a multi-file backup blocked
a tokio worker for hundreds of milliseconds to seconds while the
runtime kept serving other work on the remaining workers.

Move the PROFILE_WRITE_LOCK acquisition after the extraction, right
before finalize_restored_verge_config: the lock protects the
config-data transition (extracted files reloaded into drafts), not the
plain filesystem writes, so profile operations no longer queue behind
the blocking unzip.

* fix(clash): take config write lock when changing mode

change_clash_mode committed the clash draft and saved config.yaml without
claiming CONFIG_WRITE_LOCK, so a concurrent patch_clash transaction could
have its staged draft committed here or its file-snapshot rollback swallow
the mode change. Take lock_config_write (waiting) rather than
try_lock_config_write: two of the three callers (hotkey, tray) discard the
result, so fail-fast would silently drop the switch, and the lock is only
attempted after the mihomo patch already applied, where an Err would roll
the frontend back while the core runs the new mode.
2026-11-03 10:48:44 +00:00
Tunglies 9a904af3aa refactor: backend cleanup (#8436)
* refactor(utils): drop dead reset helper and tidy init_resources

- remove resolve_reset_async, which had no callers
- drop the duplicated time.*.com entry from the default fake-ip-filter
- init_resources no longer recreates app_home (ensure_directories during
  init_config already created it) and now propagates res_dir creation errors

* refactor(profile): remove identical blind retry when switching node

The second select_node_for_group call in switch_proxy_node was a
zero-interval retry with identical arguments. Its success path was a
degraded copy of the first (missing the Refresh::Proxies announcement
ever since the old force-refresh emit was removed), and its distinct
"回退/最终失败" log wording misled maintainers. Keep a single call,
the primary success aftermath, and one error log.

* refactor(enhance): simplify tun mode check and lowercase helpers

- Drop the always-true second clause in use_tun's enhanced-mode check:
  the key is absent only when the unwrap_or default already makes
  current_mode == "fake-ip" true.
- Collapse use_lowercase into a delegation to use_lowercase_owned.

* refactor(core): drop unreachable hotkey dtor and dedupe tray predicate

Remove impl Drop for Hotkey: the instance lives in a OnceLock static via
the singleton! macro and is never dropped; exit-time unregistration is
already handled by Hotkey::reset().

Spell the include_proxy_groups/display-mode predicate identically at
both evaluation sites in tray menu building; behavior is unchanged
(Option != Some("disable") is equivalent to unwrap_or("default") != "disable").

* refactor(config): fold port guards into single helper

The five IClashTemp port guards duplicated the same read/parse/default
logic with drifting defaults: some hardcoded 7895/7898/7899 while others
referenced network::ports constants. Route them all through one
guard_port_value helper and use the constants everywhere, keeping each
guard's name, key and cfg attributes.

* refactor(config): dedupe prfitem factories and companion seeding

from_rules/from_proxies/from_groups differed only in uid prefix, type
string and template, so they now delegate to a shared from_seq_item.
The five current_* accessors collapse into a local macro. The identical
five-block companion seeding in from_local and from_url moves into
ensure_companion_items, preserving seeding order and error propagation.

* refactor(config): share patch macro and profiles helpers

IVerge::patch_config, IProfiles::patch_item and PrfOption::merge each
hand-rolled the same copy-Some-overwrite pattern; they now share one
patch_opt_field macro (the or-else merge form is field-for-field
equivalent for Option fields). The four profiles *_safe wrappers that
inlined Config::profiles().with_data_modify boilerplate delegate to a
with_profiles_edit helper, and record/forget_selected_node share the
current-item lookup prelude via current_item_mut.

* refactor(enhance): merge chain processing and drop unused builtin js payload

- collapse the identical process_global_items/process_profile_items into
  one parameterized process_override_items (entry uid sources unchanged)
- fold the identical rules/proxies/groups arms of ChainItem::from_async
  into a single arm with an inner dispatch
- stop embedding builtin JS payloads never executed by any path: the
  enhance loop dispatches builtin items by uid to native implementations,
  so ChainItem::builtin now carries empty script data (uids unchanged)
- source ProfileItems fallback literals from Default instead of a second
  hand-written copy in collect_profile_items

* refactor(net): reuse webpki tls config and fallback

Extract build_webpki_rooted_tls_config so the delay-test TLS connector
and the reqwest clients share one static-webpki-roots builder; ALPN
stays caller-specific (only reqwest offers h2).

Collapse the duplicated platform-verifier -> static-webpki-roots retry
in get/get_bytes into request_with_static_webpki_fallback, gated by the
caller's certificate-verification setting.

* refactor(app): extract common exit cleanup

restart_app and quit duplicate the whole exit preparation: set the
exiting flag, persist configs, run interactive cleanup, and on a failed
core stop clear the flag, restore public DNS under the lifecycle lock
on macOS, and send the stop-failed notice.

Move that flow into run_exit_cleanup_flow next to clean_async, taking
the notice variant as a parameter and returning the CleanupResult so
each caller keeps its own tail: restart aborts before shutdown, quit
always shuts down and exits with the cleanup code.

* refactor(backup): share restore finalize path

restore_webdav_backup and restore_local_backup duplicated the
credential extraction (backup creation strips the WebDAV credentials,
so both re-inject the live ones), the archive extraction and the
finalize tail. Move that tail into a private restore_backup_archive
helper so the two entries keep only their download/locate logic; the
config-write lock scope and the off-runtime extraction land in one
place. The webdav entry keeps its downloaded-file cleanup.

* refactor(sysopt): drop redundant lock and busy-wait

The proxy target pair sat behind an Arc<RwLock> with no readers and an
Arc that was never cloned; both writers already serialize on
update_lock, so store the pair inside that mutex and delete the RwLock.

refresh_guard waited for the guard to become ready with a yield_now spin
at full CPU while holding guard_operation_lock; a 10 ms sleep keeps the
same wait condition and lock scope without the spin.

* refactor(sysopt): share macos bypass formatting

The macOS default bypass list and its three-way composition (empty ->
default, default + custom, custom only) were maintained twice: in
sysopt's format_bypass and inline in proxy_control's service_bypass.
Keep the literal once in constants and compose both paths through
format_bypass; service_bypass keeps its NUL check and UTF-8 truncation.

* refactor(core): dedupe window listeners, elevated runners, backup settings

- Collapse the close/focus window listener pairs in lightweight.rs onto a shared WindowListener with per-listener event and log label.
- Extract the windows runas-or-direct execution into service::run_maybe_elevated, shared by win_uwp and the service uninstaller, and factor the common uninstaller path resolution and status check shared across platforms.
- Make the auto-backup watch channel the single settings source, replacing the duplicate Arc<RwLock> copy with a stored receiver.

* refactor(enhance): fold lowercase helpers into single owned function

use_lowercase_owned disappears; the surviving use_lowercase takes the
owned Mapping and borrowed callers clone at the call site.

* refactor(enhance): rename ChainSupport variants to Mihomo names

Clash is a legacy name; the variants match the verge-mihomo and
verge-mihomo-alpha cores they gate.

* refactor(enhance): lowercase takes a reference and chain support is copy

use_lowercase borrows the mapping and clones only the values; call
sites no longer clone whole configs. ChainSupport is a fieldless enum
and now Copy; builtin() registers each support pair without clones.

* refactor(config): name prfitem constructor for what it builds

new_seq_item states that a fresh item is built from a template; the
from_ prefix wrongly implied an existing source. append_companion_item
returns the uid directly: append_item bails on a missing uid, so the
Option was always Some on the Ok path.

* refactor(sysopt): name the proxy state lock for its full role

The lock now owns the (Sysproxy, Autoproxy) state, so proxy_state_lock
says what it guards; the guard locals follow. constants::proxy held a
single sysproxy constant and is renamed sysproxy.

* refactor: replace remaining clash meta naming with mihomo

Doc comment, generated bindings and the prebuild sidecar helpers stop
referring to the legacy Clash Meta name; installer legacy-process
cleanup keeps the old executable names on purpose.

* refactor(enhance): drop builtin core filtering that never discriminates

Both valid cores matched a different ChainSupport variant, yet each
variant registered the same uid pair, so the filtered builtin list was
identical for stable and alpha. Invalid cores are coerced at startup
(validate_and_fix_config / get_valid_clash_core) and the None arm of
is_support was unreachable. builtin() now returns the items directly
and the clash_core threading through ConfigValues goes away; re-add
core discrimination when a builtin actually differs per core.

* refactor(enhance): drop unreachable mode script guard

merge_default_config overwrites the profile's mode with the verge-owned
clash config, whose mode is always present (template seeds rule and
patch semantics never clear it) and restricted to rule/global/direct;
change_clash_mode is rejected by the core before persisting anything
else. mode:script can therefore only arrive through a hand-crafted
patch_clash call, and for that path an explicit invalid-mode validation
error is more honest than silently rewriting to rule. mihomo rejects
mode:script outright (invalid mode), and user merge/script chains run
after the builtins, so the guard never covered them anyway.

* refactor(enhance): call the hysteria alpn builtin directly

builtin() existed to feed the uid-dispatch loop; with a single
builtin the vec and match are ceremony. apply_builtin_scripts calls
builtin_hy_alpn directly and to_script goes away; tests construct
ChainItem literals the way the merge items already did.
2026-11-03 10:48:44 +00:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> d7964e6d80 Dev: v2.6.0 (#8537)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 10:07:50 +00:00
Tunglies 6aeae2f970 fix(ci): share build caches and mention the release team 2026-11-03 17:57:22 +08:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> c83f7ad529 Release: v2.5.10 (#8533)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-03 17:23:02 +08:00
Tunglies 55bb74c36d fix: changelog 2026-11-03 17:19:17 +08:00
wonfen 5b7e876ab0 refactor(service): simplify DNS override sync and runtime asset staging 2026-11-03 09:34:16 +08:00
wonfen bcbb5e3a58 fix(dns): save the DNS override decision on every runtime replacement
Refs #8431
2026-11-03 09:34:16 +08:00
wonfen 726b2b362f fix(service): sync core-updated geo databases before validation 2026-11-03 09:34:15 +08:00
wonfen a038e01cd5 fix(service): stage file-based ssh private keys for the service core
The service core runs with its own home directory, so relative and absolute
private-key paths no longer resolved. Stage them as runtime assets under an
opaque name; UNC and device paths are left to the core.

Refs #8445, #8229
2026-11-03 09:34:15 +08:00
❤是纱雾酱哟~ f149604eb6 fix(installer): prompt to terminate running instance before uninstall or overwrite (#8519)
* fix(installer): prompt to terminate running instance before uninstall or overwrite

* fix(installer): detect running application instance during installation

* docs(changelog): add changelog entry for windows installer fix

Add release note entry for detecting running instances before reinstalling or overwriting in the Windows installer.

Assisted-by: VSCode Copilot SDK (Gemini 3.8 Flash)
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-11-02 17:59:59 +00:00
Tunglies ebd78ae9ff revert(macos): restore Dock hiding when closing the window
This reverts commit 12f842d038.

Restore the Accessory activation policy when closing the main window or
using Cmd+W so the Dock icon disappears again. Keeping the Regular policy
avoided menu bar jitter but caused the Dock visibility regression in #8513.

Hiding the application before switching to Accessory did not reliably
eliminate the jitter. An isolated Tao 0.37.2 checkout with a native muda
menu reproduced it on macOS 27.2 without CVR or WebView. The responsible
layer remains undetermined and needs separate investigation.

The menu bar jitter reported in #8319 can still occur; this revert restores
Dock behavior and does not claim to fix that issue.

Closes #8513
2026-11-03 00:55:35 +08:00
Tunglies 3027e2b87f fix(proxies): restore GLOBAL selection in global mode
Show the GLOBAL group before configured groups so the global exit can be
selected without changing a shared policy group. Use the displayed groups
for delay subscriptions and verify row identity before reusing the list.

Close #8516
2026-11-02 22:25:35 +08:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> a0e57c5962 Dev: v2.5.10 (#8511)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-02 10:47:51 +00:00
Tunglies de15283ef6 fix(release): omit comments when archiving changelog 2026-11-02 18:22:34 +08:00
Tunglies 3f39c2ceca fix(release): filter draft lookup before buffering output 2026-11-02 17:36:27 +08:00
github-actions[bot]andgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 160bb4fe8e Release: v2.5.9 (#8508)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-11-02 17:26:28 +08:00
Tunglies a5b22fa512 fix(webdav): allow clearing saved configuration
Close #8495
2026-11-02 17:24:13 +08:00
Tunglies 07ee0a602a ci(release): unify RC and stable publication through dev 2026-11-02 00:46:06 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 693f2f7ac7 chore(deps): update npm dependencies (#8490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 13:08:26 +00:00
nrenvo 633f166e46 ci: trigger rust checks when their configuration changes (#8487) 2026-11-01 12:40:42 +00:00
Tunglies 261178b6d8 fix(updater): render release notes without remark-gfm
Remove GFM extensions that require unsupported regular expressions on older WebKit. Keep native Markdown links, existing release-note styling, and HTML sanitization.

Closes #8079
Closes #8082
2026-11-01 16:35:01 +08:00
Tunglies 0cae907de6 fix(config): wait for pending configuration writes
Serialize Verge patches behind the active configuration writer so toggling the system proxy after TUN does not fail immediately on lock contention.

CLose: #6613, #8481, #8357
2026-11-01 16:13:11 +08:00
nrenvo 94942b9105 fix(backup): publish scheduler settings with send_replace (#8386)
`watch::Sender::send` drops the value when there are no receivers at
startup, leaving the scheduler on the default disabled settings.
`send_replace` stores it for the runner that subscribes right after.
2026-11-01 07:43:42 +00:00
Tunglies 12f842d038 fix(macos): prevent menu bar flicker when closing the window
Keep the application activation policy unchanged when hiding the main window. Lightweight mode still hides the Dock icon.

Closes #8319
2026-11-01 15:33:26 +08:00
Tunglies 8e9832712a doc: clash-verge-rev with mihomo 1.19.33 2026-11-01 14:59:14 +08:00
Tunglies 719f63b57f chore: update tao and wry 2026-11-01 14:30:28 +08:00
heeeex100 ef784e459d docs: document complete development setup (#8457)
* docs: document complete development setup

* docs: follow review feedback on toolchain setup
2026-10-30 15:48:33 +00:00
❤是纱雾酱哟~andCopilot c236f5094e deps: upgrade updater to remove vulnerable zip (#8476)
* fix(deps): update updater to patched zip release

Upgrade tauri-plugin-updater to 2.14.1 and resolve the lockfile without vulnerable zip 4.6.1.

Assisted-by: Copilot SDK

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

* chore(deps): align updater package versions

Upgrade the PNPM updater plugin to match the Rust updater crate at 2.14.1.

Assisted-by: Copilot SDK

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

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-30 15:04:34 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> bdea31a7ca chore(deps): update dependency pnpm to v12.16.1 (#8475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 15:04:11 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 44d50eb67d chore(deps): update rust crate zip to v9.1.3 [security] (#8464)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 03:52:54 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> afd9d4b5a0 chore(deps): update rust crate zip to v9.1.2 [security] (#8461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 22:45:12 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8bf20d199c chore(deps): update rust crate zip to v9.1.1 [security] (#8460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:26:16 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e156ff09ae chore(deps): update rust crate zip to v9.1.0 [security] (#8458)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 13:39:08 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> df70146b02 chore(deps): update npm dependencies (#8453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 10:37:26 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> de22706f3b chore(deps): update rust crate zip to v9.0.5 [security] (#8454)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:16:28 +00:00
UddooandTunglies 4b87634da9 fix(macos): restore Big Sur home rendering without Array.at (#8358)
* fix(macos): restore Big Sur home rendering without Array.at

Include the Array.prototype.at polyfill for the declared Safari 14 target so delay and traffic rendering can read their latest records.

Fixes clash-verge-rev/clash-verge-rev#8333

AI assistance: Codex generated the change and ran validation; the implementation model identifier and effort are not exposed. Independent review used GPT-6.1-Sol at medium effort.

* build(web): auto-detect modern polyfills instead of a manual list

modernPolyfills: true lets @vitejs/plugin-legacy derive the polyfill
set from the emitted bundle, dependencies included, so post-Safari-14
API coverage no longer depends on remembering to extend the list
(#8333, and the dependency-side gaps behind #1126 and #6582).

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-10-29 08:04:33 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> c9a6cf240d chore(deps): update node.js to v26 (#8450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 04:21:49 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8128227302 chore(deps): update dependency react-i18next to v17.1.9 (#8448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 17:33:10 +00:00
Shubham Padkonde 3953e125b2 fix(profile): ignore null entries in the rules editor (#8432) 2026-10-27 17:39:05 +00:00
nrenvo cbd5ed73a5 fix(theme): allow injected CSS to style Monaco view containers (#8385)
`@scope`'s `to` bound excludes the boundary elements themselves, so rules
targeting `.view-lines` never matched. Move the bound to its children
with `> *`: the container becomes styleable, its internals stay excluded.
2026-10-27 15:13:58 +00:00
Tunglies 02e5e0e4a6 ci: gate frontend and rust checks by changed paths (#8433)
The paths-filter output in lint-clippy.yml only gated two echo steps;
checkout, toolchain setup and clippy itself ran unconditionally, so
frontend-only PRs still burned a 3-runner matrix on a full clippy build
plus the logging-check tool install.

- lint-clippy/rustfmt/frontend-check: move path filters to workflow
  level so irrelevant PRs spawn no runner at all
- split contract-parity into its own workflow; its trigger set spans
  both sides and workflow paths cannot vary per job
- clippy paths now cover crates/** and workspace Cargo files, which the
  old src-tauri/** filter missed

dev has no required status checks, so path-filtered workflows cannot
get stuck in "Expected".
2026-10-27 16:36:49 +08:00
0498ba3e4f fix(proxies): render configured groups in global mode (#8402)
* fix(proxies): render configured groups in global mode

Global mode previously only rendered the synthetic GLOBAL group and
sliced off its header, which flattened every user-defined group into
a non-expandable proxy row. Configured groups could no longer be
opened or inspected. Render the same group list as rule/script mode
so every group keeps its own header and expand toggle.

Fixes #8376

* style: keep useRule assignment on one line

* fix & changelog

* fix: hover when global proxy

---------

Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-10-27 01:41:19 +00:00
Tunglies f39644ee6b chore(typecheck): replace tsc with tsrs as the typecheck gate (#8428)
tsrs (Rust port of the TypeScript 7 checker) replaces tsc --noEmit in
the typecheck script, web:build, and CI. Measured on this repository:
~0.4s vs ~3.5s wall time and ~280 MB vs ~750 MB peak RSS, with
identical zero-diagnostic results on the current tree. The typescript
package stays installed: typescript-eslint projectService and
scripts/cleanup-unused-i18n.mjs consume its compiler API.
2026-10-27 01:34:29 +00:00
488f022c05 feat(media-unlock): add Muse AI (muse.ai) detection (#8411)
* feat(media-unlock): add Muse AI detection

muse.ai redirects every region to Meta's aymh anti-bot gateway before the app loads, and a plain HTTP client cannot finish the JavaScript challenge. Report the result as a new Risky status (exit reachable, availability unverifiable) together with the exit region instead of a misleading Yes/No.

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

* style(media-unlock): apply rustfmt and biome formatting

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

* docs(changelog): Update changelog

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

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: susu-Sugar <214122492+susu-Sugar@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-26 17:17:09 +00:00
Ekko82 eefd025806 feat(home): add an IP auto-refresh toggle (#8403) 2026-10-26 02:04:55 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 10ca0a5d0c chore(deps): update npm dependencies (#8421)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 16:24:26 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 324c8c9016 chore(deps): update dependency pnpm to v12.15.0 (#8418)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-25 15:35:18 +00:00
oomeow 02520d7392 fix(tray): skip Win-key accelerators on Windows (#8400) 2026-10-25 09:34:09 +00:00
Tunglies 5b8edbaead feat(tun): expose mihomo tun dns-mode in TUN settings
Support the dns-mode field introduced in mihomo v1.19.33. A
button group below the stack selector offers
Auto/Disabled/Native/Hijack; Auto writes an empty string, which
the core treats as the legacy behavior. Adding dns-mode to
GUI_KEYS makes the value GUI-authoritative once saved, matching
the other TUN dialog fields.
2026-10-25 16:04:59 +08:00
Tunglies 1ed8050947 fix(perf): auto-derive WebDriver window rect scale in harness 2026-10-25 13:14:14 +08:00
❤是纱雾酱哟~ 3d9a79a3fb fix(windows): patch executable for common-controls-v6 (#8378)
- Enhance patch logic, also patch for "example" executables

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-22 09:59:10 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8b88ddb7f2 chore(deps): update node.js to v24.22.1 (#8389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-21 17:25:15 +00:00
Tunglies 02bd739ab6 docs: clarify issue and PR intake rules 2026-10-21 19:36:50 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
b6c7af82a7 chore(deps): update tauri (#8346)
* chore(deps): update tauri

* fix(deps): pin rust-ini to 0.21.1 for tauri-plugin-deep-link 2.6.2

tauri-plugin-deep-link 2.6.2 constructs ini::ParseOption with an
exhaustive struct literal, which fails to compile against rust-ini
0.21.2+ (new required fields enabled_indented_mutiline_value and
enabled_preserve_key_leading_whitespace, E0063 on linux). Upstream
fixed it in tauri-apps/plugins-workspace#3711 with ..Default::default()
but no release contains the fix yet, so downgrade rust-ini in Cargo.lock
until deep-link 2.6.3 ships.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-10-21 08:25:55 +00:00
wonfen 5e6a696087 revert(tray): revert per-group latency testing (#8173)
This reverts commit cb751052a4.

Remove the tray latency test and its dashboard URL persistence and cache
changes because the two entry points have diverging behavior.

Preserve the subsequent IPC binding migration and remove generated fields,
input projections, and locale entries that depended on the reverted feature.
2026-10-21 05:37:18 +08:00
❤是纱雾酱哟~ 27d8a71d41 Revert "fix(profiles): refresh remote profile name from server on update (#8360)" (#8366)
This reverts commit 9dd5aa676b.
2026-10-20 15:52:35 +00:00
susu-Sugarand❤是纱雾酱哟~ 9dd5aa676b fix(profiles): refresh remote profile name from server on update (#8360)
* fix(profiles): refresh remote profile name from server on update

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

* chore(changelog): Update changelog for the PR

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

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-10-19 17:41:33 +00:00
wonfen 4e5ac8683b feat(proxy): confirm before enabling chain proxy
Show the speed warning with explicit enable/keep-disabled actions, and add a close button to the empty chain panel.
2026-10-19 20:01:43 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
7965711945 chore(deps): update cargo dependencies (#8323)
* chore(deps): update cargo dependencies

* chore(deps): revert dependency rfd to 0.16.0

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

---------

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

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

* fix(eslint): Clean unused imports

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

---------

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

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

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

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

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

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

* fix: clippy

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

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

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

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

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

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

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

---------

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

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

* fix(events): remove unused refresh payloads

* fix(webdav): return only consumed backup metadata

* fix(network): trim unused interface metadata

* fix(sysinfo): return only displayed system fields

* chore(knip): check generated runtime exports

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

* fix(clipboard): propagate proxy environment copy failures

* refactor(dns): remove ignored runtime patch

* refactor(core): trim unused upgrade response fields

* refactor(ipc): avoid redundant response copies

* refactor(proxy): clarify automatic proxy query naming

* chore(tests): remove added IPC regression tests

* refactor(ipc): enforce typed command boundaries

* chore(contract): check generated bindings before push

* refactor(ipc): remove unused proxy liveness payloads
2026-10-12 06:16:05 +00:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 27efaf34f4 chore(deps): update dependency pnpm to v12.11.2 (#8252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 16:06:46 +00:00
285 changed files with 9023 additions and 6851 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.10.1"
"version": "26.13.0",
"pnpmVersion": "12.16.1"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.0",
"version": "1.99.1",
"profile": "default"
},
"ghcr.io/devcontainers/features/git:1": {},
+33
View File
@@ -0,0 +1,33 @@
# Issue Guidelines / Issue 提交指引
Use these suggestions to make reports easier to assess. State what you observed
and what you could not verify; unknown details are fine.
以下建议有助于维护者评估问题。请说明实际观察到的现象和无法确认的部分;未知信息可以留空。
- Search existing issues by symptom, conditions, and outcome. Add new evidence
to an existing report when it describes the same problem.
按现象、条件和结果搜索已有 Issue。若描述的是同一问题,请在原 Issue 中补充新证据。
- Keep each issue to one distinct user problem or requested outcome. Split
unrelated findings instead of bundling an audit or checklist.
每个 Issue 聚焦一个独立的问题或需求结果;无关发现请拆开,不要把审计清单打包成一个 Issue。
- For bugs, include the version, operating system, steps, and actual versus
expected behavior when known. A root-cause diagnosis is not required. Share
only relevant logs and redact tokens or personal information.
反馈 Bug 时,尽可能提供版本、操作系统、复现步骤及实际与预期行为。无需先判断根因;只提供相关日志,并遮盖令牌和个人信息。
- For feature requests, describe the user task, current limitation, desired
outcome, and affected platforms. An implementation proposal is optional.
提交功能请求时,请描述用户要完成的任务、当前限制、期望结果和适用平台;实现方案不是必填项。
- Separate observations from guesses. An AI or scanner finding is a hypothesis
until its affected configuration, concrete impact, and supporting evidence
are stated; do not present a list of theoretical risks as confirmed bugs.
区分观察结果和推测。AI 或扫描器的发现应先视为假设;请说明受影响的配置、具体影响和支持证据,不要把理论风险清单写成已确认的 Bug。
- Do not post secrets, personal data, or sensitive exploit details in a public
issue. Ask maintainers how to share sensitive security information safely.
请勿在公开 Issue 中发布密钥、个人信息或敏感利用细节;如涉及安全敏感信息,请先询问维护者如何安全提供。
- AI assistance is welcome; human contributors do not need to disclose it. If
an AI agent or automation creates or co-authors an issue, add a final body line
such as `Assisted by: GPT-5.6 High`. Omit effort when the runtime does not
report it. Disclosure is transparency only, not evidence for or against issue
quality.
欢迎使用 AI 辅助,人工提交者无需披露。如果 Issue 由 AI Agent 或自动化创建或共同撰写,请在正文末尾添加披露行,例如 `Assisted by: GPT-5.6 High`。运行环境未提供 effort 时可省略。披露仅用于提高透明度,不作为 Issue 质量的正面或负面证据。
+4
View File
@@ -10,6 +10,8 @@ body:
value: |
## 在提交问题之前,请确认以下事项:
提交前可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),说明可观察到的问题和证据;不确定的原因请标为猜测。
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 以及 [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给 issue 填写一个简洁明了的标题,以便他人快速检索
@@ -19,6 +21,8 @@ body:
## Before submitting the issue, please make sure of the following checklist:
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for how to describe observed behavior and evidence; mark uncertain causes as hypotheses.
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) and [FAQ](https://clash-verge-rev.github.io/faq/windows.html)
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
@@ -9,12 +9,16 @@ body:
attributes:
value: |
## 在提交问题之前,请确认以下事项:
可参考 [Issue 提交指引](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md),描述用户任务、当前限制和期望结果。
1. 请 **确保** 您已经查阅了 [Clash Verge Rev 官方文档](https://clash-verge-rev.github.io/guide/term.html) 确认软件不存在类似的功能
2. 请 **确保** [已有的问题](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue) 中没有人提交过相似 issue,否则请在已有的 issue 下进行讨论
3. 请 **务必** 给issue填写一个简洁明了的标题,以便他人快速检索
4. 请 **务必** 先下载 [AutoBuild](https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/autobuild) 版本测试,确保该功能还未实现
5. 请 **务必** 按照模板规范详细描述问题,否则 issue 将会被关闭
## Before submitting the issue, please make sure of the following checklist:
See the [Issue guidelines](https://github.com/clash-verge-rev/clash-verge-rev/blob/dev/.github/ISSUE_GUIDELINES.md) for describing the user task, current limitation, and desired outcome.
1. Please make sure you have read the [Clash Verge Rev official documentation](https://clash-verge-rev.github.io/guide/term.html) to confirm that the software does not have similar functions
2. Please make sure there is no similar issue in the [existing issues](https://github.com/clash-verge-rev/clash-verge-rev/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
+7 -1
View File
@@ -1,6 +1,12 @@
## Problem
What breaks or what is missing. Link the issue if one exists (`Fixes #123`).
What breaks or what is missing. Product bug fixes and features require a
pre-existing issue; link it here (`Fixes #123` only when this PR fully resolves
it). Maintenance work (such as chore, perf, docs, refactor, test, CI/build,
dependency, style, routine localization, or release changes) does not require
an issue link; link one when it defines the scope. For reverts, link the PR or
commit being reverted. For security-sensitive fixes that cannot be tracked
publicly, follow maintainer-approved private tracking.
## Approach
+15 -60
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -154,18 +154,15 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -185,17 +182,9 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -204,12 +193,6 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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@v1
env:
@@ -257,18 +240,15 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -282,17 +262,9 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -345,12 +317,6 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -404,15 +370,18 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
workspaces: |
. -> target
cache-all-crates: true
@@ -426,17 +395,9 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
- name: Pnpm install and check
run: |
pnpm i
@@ -452,12 +413,6 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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@v1
@@ -524,7 +479,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6.1.0
name: Install pnpm
+80
View File
@@ -0,0 +1,80 @@
name: Contract Parity
on:
pull_request:
paths:
- "src-tauri/**/*.rs"
- "crates/**/*.rs"
- "Cargo.toml"
- "Cargo.lock"
- "src-tauri/Cargo.toml"
- "crates/**/Cargo.toml"
- "rust-toolchain.toml"
- "src-tauri/tauri.conf*.json"
- "src/services/bindings.ts"
- "scripts/gen-contract.mjs"
- "scripts/check-contract-changes.mjs"
- "biome.json"
- "biome.jsonc"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "scripts/contract-inputs.json"
- ".github/workflows/contract-parity.yml"
- ".husky/pre-push"
workflow_dispatch:
permissions:
contents: read
env:
HUSKY: 0
jobs:
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Provision sidecar placeholders for tauri-build
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
run: pnpm gen:contract --check
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+5 -13
View File
@@ -74,14 +74,15 @@ jobs:
- name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.0
uses: dtolnay/rust-toolchain@1.99.1
- name: Rust Cache
if: github.event.inputs[matrix.input] == 'true'
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -103,18 +104,9 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
lookup-only: true
- name: Pnpm install and check
if: github.event.inputs[matrix.input] == 'true'
run: |
@@ -129,7 +121,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
+14 -120
View File
@@ -2,6 +2,19 @@ name: Frontend Check
on:
pull_request:
paths:
- "src/**"
- "**/*.js"
- "**/*.ts"
- "**/*.tsx"
- "**/*.css"
- "**/*.scss"
- "**/*.json"
- "**/*.md"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "vite.config.*"
- ".github/workflows/frontend-check.yml"
workflow_dispatch:
permissions:
@@ -16,151 +29,32 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check frontend changes
id: check_frontend
uses: dorny/paths-filter@v4
with:
filters: |
frontend:
- 'src/**'
- '**/*.js'
- '**/*.ts'
- '**/*.tsx'
- '**/*.css'
- '**/*.scss'
- '**/*.json'
- '**/*.md'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'knip.json'
- 'eslint.config.ts'
- 'tsconfig.json'
- 'vite.config.*'
- name: Skip if no frontend changes
if: steps.check_frontend.outputs.frontend != 'true'
run: echo "No frontend changes, skipping frontend checks."
- name: Install pnpm
if: steps.check_frontend.outputs.frontend == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: '24.21.0'
node-version: '26.13.0'
cache: 'pnpm'
- name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
uses: actions/cache@v6
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}"
restore-keys: |
pnpm-shared-stable-${{ runner.os }}-
- run: pnpm install --frozen-lockfile
if: steps.check_frontend.outputs.frontend == 'true'
- name: Run Prettier check
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm format:check
- name: Run ESLint
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm lint
- 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
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: |
contract:
- 'src/services/contract.ts'
- 'scripts/gen-contract.mjs'
- 'src-tauri/src/core/notify/**'
- 'src-tauri/src/bin/**'
- 'src-tauri/src/lib.rs'
- '.github/workflows/frontend-check.yml'
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Regenerate contract and require empty diff
if: steps.check_inputs.outputs.contract == 'true'
run: |
pnpm gen:contract
git diff --exit-code -- src/services/contract.ts
+9 -23
View File
@@ -2,6 +2,14 @@ name: Clippy Lint
on:
pull_request:
paths:
- "src-tauri/**"
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/config.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch:
env:
HUSKY: 0
@@ -23,28 +31,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Check src-tauri changes
if: github.event_name != 'workflow_dispatch'
id: check_changes
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- name: Skip if src-tauri not changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust != 'true'
run: echo "No src-tauri changes, skipping clippy lint."
- name: Continue if src-tauri changed
if: github.event_name != 'workflow_dispatch' && steps.check_changes.outputs.rust == 'true'
run: echo "src-tauri changed, running clippy lint."
- name: Manual trigger - always run
if: github.event_name == 'workflow_dispatch'
run: |
echo "Manual trigger detected: skipping changes check and running clippy."
- name: Checkout Repository
uses: actions/checkout@v7
@@ -79,7 +65,7 @@ jobs:
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')
if: matrix.os == 'windows-latest'
working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }}
+79
View File
@@ -0,0 +1,79 @@
name: Prepare Release
on:
pull_request_target:
types: [edited, closed]
branches: [dev]
workflow_dispatch:
inputs:
channel:
description: Release channel
required: true
type: choice
options: [rc, stable]
default: rc
release_tag:
description: 'Optional tag: vX.Y.Z-rc[.N] for RC, vX.Y.Z for stable'
required: false
type: string
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
jobs:
prepare:
if: >-
${{ github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
((startsWith(github.event.pull_request.head.ref, 'release/v') &&
((github.event.action == 'closed' && github.event.pull_request.merged == true) ||
(github.event.changes.body && github.event.pull_request.state == 'open' &&
contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev')))) ||
(startsWith(github.event.pull_request.head.ref, 'next-dev/v') &&
github.event.changes.body && github.event.pull_request.state == 'open' &&
(contains(github.event.pull_request.body, '[x] Refresh this PR from latest dev') ||
contains(github.event.pull_request.body, '[x] Use the next minor version') !=
contains(github.event.changes.body.from, '[x] Use the next minor version'))))) }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Require the dev branch
if: github.event_name == 'workflow_dispatch'
env:
SOURCE_REF: ${{ github.ref }}
run: |
if [[ "$SOURCE_REF" != 'refs/heads/dev' ]]; then
echo '::error::Run Prepare Release from the dev branch.'
exit 1
fi
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
if: github.event.action != 'closed'
with:
run_install: false
- run: pnpm install --frozen-lockfile --ignore-scripts
if: github.event.action != 'closed'
- name: Create or refresh version or publication PR
env:
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_CHANNEL: ${{ inputs.channel }}
RELEASE_COMMAND: ${{ github.event.action == 'closed' && 'publish-pr' || startsWith(github.event.pull_request.head.ref, 'next-dev/') && 'next-dev' || 'prepare' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
+144 -121
View File
@@ -1,6 +1,9 @@
name: Release Build
on:
pull_request:
types: [edited]
branches: [main]
push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags:
@@ -11,75 +14,59 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
group: release-publication
cancel-in-progress: false
jobs:
check_tag_version:
name: Check Release Tag and package.json Version Consistency
prepare_release:
name: Approve Snapshot and Prepare Draft Release
if: >-
${{ github.event_name == 'push' ||
(github.event.changes.body &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login == 'github-actions[bot]' &&
startsWith(github.event.pull_request.head.ref, 'publish/v') &&
contains(github.event.pull_request.body, '[x] Publish this approved release')) }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
outputs:
release_tag: ${{ steps.source.outputs.release_tag }}
source_sha: ${{ steps.source.outputs.source_sha }}
channel: ${{ steps.source.outputs.channel }}
release_id: ${{ steps.draft.outputs.id }}
steps:
- name: Checkout repository
- name: Checkout trusted release tooling
uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- name: Check if tag is from main branch
run: |
TAG_REF="${GITHUB_REF##*/}"
echo "Checking if tag $TAG_REF is from main branch..."
TAG_COMMIT=$(git rev-list -n 1 $TAG_REF)
MAIN_COMMITS=$(git rev-list origin/main)
if echo "$MAIN_COMMITS" | grep -q "$TAG_COMMIT"; then
echo "✅ Tag $TAG_REF is from main branch"
else
echo "❌ Tag $TAG_REF is not from main branch"
echo "This release workflow only accepts tags from main branch."
exit 1
fi
- name: Check tag and package.json version
run: |
TAG_REF="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
echo "Current tag: $TAG_REF"
PKG_VERSION=$(jq -r .version package.json)
echo "package.json version: $PKG_VERSION"
EXPECTED_TAG="v$PKG_VERSION"
if [[ "$TAG_REF" != "$EXPECTED_TAG" ]]; then
echo "❌ Version mismatch:"
echo " Git tag : $TAG_REF"
echo " package.json : $EXPECTED_TAG"
exit 1
fi
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
- uses: actions/setup-node@v7
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') }}
node-version: '26.13.0'
- name: Validate source and publish the approved snapshot
id: source
env:
RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }}
run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
- name: Create or reuse draft release
id: draft
env:
RELEASE_TAG: ${{ steps.source.outputs.release_tag }}
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: node scripts-workflow/release.mjs prepare-draft
update_tag:
name: Update tag
name: Publish Completed Release
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
outputs:
source_sha: ${{ needs.prepare_release.outputs.source_sha }}
channel: ${{ needs.prepare_release.outputs.channel }}
runs-on: ubuntu-latest
needs:
[
@@ -91,6 +78,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -103,7 +92,7 @@ jobs:
- name: Set Env
run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
TAG_REF="${GITHUB_REF##*/}"
TAG_REF="$RELEASE_TAG"
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
VERSION=$(echo "$TAG_REF" | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -149,10 +138,16 @@ jobs:
Created at ${{ env.BUILDTIME }}.
EOF
- name: Verify release tag still points to the approved source
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
run: node scripts-workflow/release.mjs verify-target
- name: Publish Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(github.ref_name, '-rc') }}
PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
run: |
release_id="${{ needs.prepare_release.outputs.release_id }}"
@@ -181,6 +176,8 @@ jobs:
release:
name: Release Build
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -200,22 +197,21 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -227,29 +223,23 @@ jobs:
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
pnpm run prebuild ${{ matrix.target }}
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.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@v1.0.0
env:
NODE_OPTIONS: '--max_old_space_size=4096'
@@ -264,11 +254,14 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ github.ref_name }}
tagName: ${{ env.RELEASE_TAG }}
releaseBody: 'Draft release, will be updated later.'
releaseDraft: true
prerelease: ${{ needs.prepare_release.outputs.channel == 'rc' }}
releaseCommitish: ${{ needs.prepare_release.outputs.source_sha }}
tauriScript: pnpm
args: --target ${{ matrix.target }}
includeUpdaterJson: true
uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
- name: Attest Windows bundles
if: matrix.os == 'windows-latest'
@@ -293,6 +286,8 @@ jobs:
release-for-linux-arm:
name: Release Build for Linux ARM
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -307,37 +302,37 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
@@ -387,12 +382,6 @@ jobs:
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Build for Linux
run: |
export PKG_CONFIG_ALLOW_CROSS=1
@@ -420,7 +409,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
gh release upload "$RELEASE_TAG" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber
@@ -428,6 +417,8 @@ jobs:
release-for-fixed-webview2:
name: Release Build for Fixed WebView2
needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy:
fail-fast: false
matrix:
@@ -442,37 +433,37 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check
run: |
pnpm i
@@ -485,12 +476,6 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json tauri.windows.conf.json
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build
uses: tauri-apps/tauri-action@v1.0.0
env:
@@ -532,22 +517,55 @@ jobs:
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
gh release upload "$env:RELEASE_TAG" $files --clobber
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
prepare-next-dev:
name: Prepare Next Dev Version
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
needs: [prepare_release, update_tag]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release
cancel-in-progress: false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
SOURCE_SHA: ${{ needs.prepare_release.outputs.source_sha }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/heads/dev
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
with:
run_install: false
- name: Prepare version and changelog PR
run: |
pnpm install --frozen-lockfile --ignore-scripts
node scripts-workflow/release.mjs next-dev
release-update:
if: ${{ !contains(github.ref_name, '-rc') }}
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Release Update
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -563,17 +581,19 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-update-for-fixed-webview2:
if: ${{ !contains(github.ref_name, '-rc') }}
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
runs-on: ubuntu-latest
needs: [update_tag]
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -589,7 +609,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget:
if: ${{ !contains(github.ref_name, '-rc') }}
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Submit to Winget
runs-on: ubuntu-latest
needs: [update_tag, release-update]
@@ -597,6 +617,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
fetch-depth: 0
- name: Get Version
run: |
@@ -613,7 +634,7 @@ jobs:
token: ${{ secrets.WINGET_TOKEN }}
notify-telegram:
if: ${{ !contains(github.ref_name, '-rc') }}
if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Notify Telegram
runs-on: ubuntu-latest
needs:
@@ -626,6 +647,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh
@@ -638,7 +661,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+6 -15
View File
@@ -6,6 +6,12 @@ name: Check Formatting
on:
pull_request:
paths:
- "src-tauri/**"
- "**/*.rs"
- "rustfmt.toml"
- "rust-toolchain.toml"
- ".github/workflows/rustfmt.yml"
permissions:
contents: read
@@ -19,25 +25,10 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Check Rust changes
id: check_rust
uses: dorny/paths-filter@v4
with:
filters: |
rust:
- 'src-tauri/**'
- '**/*.rs'
- name: Skip if no Rust changes
if: steps.check_rust.outputs.rust != 'true'
run: echo "No Rust changes, skipping rustfmt."
- name: install Rust stable and rustfmt
if: steps.check_rust.outputs.rust == 'true'
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.0'
toolchain: '1.99.1'
targets: x86_64-pc-windows-msvc
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- name: Install pnpm
uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.21.0'
node-version: '26.13.0'
- uses: pnpm/action-setup@v6
name: Install pnpm
+1
View File
@@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then
cargo install --force cargo-make
fi
node scripts/check-contract-changes.mjs --push "$1" || exit $?
cargo make pre-push
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.10.1
rust 1.99.0
nodejs 26.13.0
pnpm 12.16.1
rust 1.99.1
+25 -21
View File
@@ -18,22 +18,25 @@ These rules apply to every change, whether human- or agent-authored. They match
the ownership evidence the AI-slop review evaluates (see
[`pr-ai-slop-review.md`](.github/workflows/pr-ai-slop-review.md)).
1. **Issue first.** Non-trivial changes require a pre-existing issue describing
the problem. If none exists, ask the maintainers to open or approve one
before implementing.
1. **Issue first.** Product bug fixes and features require a pre-existing issue;
self-scoped maintenance work does not. Follow the PR template for linkage and
revert references. If a required issue is missing, ask maintainers to open or
approve one before implementing. For security-sensitive fixes that cannot be
tracked publicly, follow maintainer-approved private tracking.
2. **Scope discipline.** Every changed file must be justifiable from the linked
issue. No drive-by refactors, renames, formatting churn, or dependency bumps
unrelated to the problem being fixed.
issue or the stated PR scope. No drive-by refactors, renames, formatting
churn, or dependency bumps unrelated to the work.
3. **Author accountability.** AI assistance is welcome, but the contributor owns
the result: understand the change, describe the problem and approach in your
own words, and verify the change against the reported behavior before
submitting.
4. **Tests are justified, not default.** Do not add tests, test scaffolding, or
speculative defensive code unless the linked issue demands them. When a test
is genuinely necessary — it reproduces the reported regression or guards
behavior whose breakage would otherwise go unnoticed — keep it minimal and
state in the PR body why it is needed. Bulk test files and defensive
programming for hypothetical failure modes are PR bloat, not rigor.
speculative defensive code unless the linked issue or stated task demands
them. When a test is genuinely necessary — it reproduces the reported
regression or guards behavior whose breakage would otherwise go unnoticed —
keep it minimal and state in the PR body why it is needed. Bulk test files
and defensive programming for hypothetical failure modes are PR bloat, not
rigor.
5. **Comments state constraints, not narration.** Write a comment only for a
non-obvious constraint the code cannot express; never restate what the code
does.
@@ -44,13 +47,12 @@ the ownership evidence the AI-slop review evaluates (see
real evidence instead: reproduction steps, failure output, targeted tests.
8. **Minimal diffs.** Match the surrounding code's style, naming, and comment
density. Do not introduce new dependencies or restructure working code unless
the issue demands it.
9. **Disclose AI automation.** When an agent produces or co-produces a change,
append a footer line to the PR body with the model and effort used (e.g.
`Assisted by: GPT-5.6 High`). The PR template intentionally omits this line —
the agent adds it itself, humans are not asked to declare anything. Effort may
be omitted when the runtime does not report it. Disclosure is transparency
only; it does not substitute for any rule above.
the linked issue or stated task demands it.
9. **Disclose AI automation.** When an agent creates or co-creates a PR or Issue,
append a final body line with the model and effort used (e.g.
`Assisted by: GPT-5.6 High`). Effort may be omitted when the runtime does not
report it. Human contributors are not asked to disclose AI assistance.
Disclosure is transparency only and carries no positive or negative weight.
10. **Compiled workflows.** The AI-slop review policy in
[pr-ai-slop-review.md](.github/workflows/pr-ai-slop-review.md) is compiled:
after editing it, run `gh aw compile` and commit the regenerated
@@ -59,8 +61,10 @@ the ownership evidence the AI-slop review evaluates (see
[`template/Changelog.md`](template/Changelog.md): one line per
user-visible change, no internals.
## Pull Request Shape
## Issue and PR Workflows
Describe three things, briefly: the problem (with issue link), why this approach
solves it, and what changed. See
[`PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
When drafting, triaging, splitting, or implementing from a community issue, use
the conditional checklist in [`.github/ISSUE_GUIDELINES.md`](.github/ISSUE_GUIDELINES.md).
Do not make GitHub issue changes without explicit authorization. For PRs, follow
the directly loaded [PR template](.github/PULL_REQUEST_TEMPLATE.md); do not
duplicate its prompts here.
+19 -6
View File
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
### Prerequisites
1. **Install Rust and Node.js**
Our project requires both Rust and Node.js. Follow the official installation instructions [here](https://tauri.app/start/prerequisites/).
Follow the [Tauri prerequisites](https://tauri.app/start/prerequisites/) for your operating system. Use the Node.js version in [`.tool-versions`](.tool-versions); [`rust-toolchain.toml`](rust-toolchain.toml) selects the Rust version and required components when you run Cargo from this repository.
### Windows Users
@@ -51,18 +51,21 @@ rustup set default-host x86_64-pc-windows-msvc
### Install Node.js Package Manager
Enable `corepack`:
[Corepack is no longer bundled with Node.js starting with Node.js 25](https://nodejs.org/api/corepack.html). Install it globally if it is not already available, then enable it:
```bash
npm install --global corepack
corepack enable
```
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
### Install Project Dependencies
Node.js dependencies:
```bash
pnpm install
pnpm install --frozen-lockfile
```
Ubuntu-only system packages:
@@ -78,14 +81,24 @@ pnpm run prebuild
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries
```
### Prepare the Development Service Source
`pnpm dev` builds the development service from a sibling checkout of [`clash-verge-service-ipc`](https://github.com/clash-verge-rev/clash-verge-service-ipc). The binaries downloaded by `pnpm prebuild` do not replace this source checkout.
Check the `clash_verge_service_ipc` dependency in [`src-tauri/Cargo.toml`](src-tauri/Cargo.toml), then clone the service repository alongside `clash-verge-rev` and check out the matching tag. If the sibling directory already exists, reuse it and check its version rather than overwriting it. Without this checkout, `pnpm dev` fails when it tries to access `../clash-verge-service-ipc/Cargo.toml`.
### Run the Development Server
Run these commands from the root of `clash-verge-rev`:
```bash
pnpm dev # Standard
pnpm dev:diff # If an app instance already exists
pnpm dev:tauri # Run Tauri development mode
pnpm dev # Build the development service and start the desktop app
pnpm dev:diff # Alias of pnpm dev
pnpm dev:tauri # Run Tauri directly with the tauri-dev feature
```
For frontend-only development, use `pnpm web:dev`. It does not start the Rust backend or build the development service.
### Build the Project
Standard build:
Generated
+236 -257
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -61,13 +61,13 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "2.12.1" }
tauri-plugin-clipboard-manager = "2.4.1"
tauri = { version = "2.12.4" }
tauri-plugin-clipboard-manager = "2.5.0"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.2", features = [
tokio = { version = "1.53.3", features = [
"rt-multi-thread",
"macros",
"time",
@@ -88,7 +88,7 @@ rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
socket2 = "0.6.5"
+23 -6
View File
@@ -1,9 +1,30 @@
## v2.5.9
<!-- Release-notes skeleton. Entries accumulate in ./Changelog.md as PRs land;
before publishing a release, distill them:
- user-visible changes only — no dev tooling, CI fixes, or refactors whose
effect is already covered by another entry
- describe outcomes, not internals: no internal module names, IPC or
protocol details, or implementation mechanics — write what the user
observes, not how the code achieves it
- one line per theme: merge entries that share a root cause
- each entry fits a single readable line (about 60 CJK characters); if it
overflows, it is packing multiple themes or detail that belongs in the
linked issue/PR — split it or drop the detail
- line grammar: open with a verb (修复/新增/优化), with the platform
first (Windows/macOS/Linux) when the change is platform-specific;
merge sub-symptoms as 主题:症状、症状; full-width punctuation,
「」 for UI labels, backticks for config keys and commands
- platform entries sit under their platform heading (bold, emoji
prefixed, ordered Windows/macOS/Linux, combined for pairs like
macOS/Linux) at the end of their section;
scripts-workflow/group_platforms.sh regroups, --check verifies
HTML comments are stripped by the update dialog's sanitizer and hidden on
GitHub, so they never reach users. -->
## v2.6.0
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复检测更新后又发布新版时,应用内更新失败
</details>
@@ -17,8 +38,4 @@
<summary><strong> 🚀 优化改进 </strong></summary>
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
-2
View File
@@ -70,8 +70,6 @@ tray:
global: عام
profiles: الملفات التعريفية
proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام
tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profile
proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy
tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profiles
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy
tunMode: TUN Mode
closeAllConnections: Close All Connections
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Perfiles
proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema
tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones
-2
View File
@@ -70,8 +70,6 @@ tray:
global: سراسری
profiles: پروفایل‌ها
proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم
tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Profil
proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem
tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi
-2
View File
@@ -70,8 +70,6 @@ tray:
global: グローバル
profiles: プロファイル
proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ
tunMode: TUN モード
closeAllConnections: すべての接続を閉じる
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 글로벌
profiles: 프로필
proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시
tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Global
profiles: Perfis
proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema
tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Глобальный
profiles: Профили
proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси
tunMode: Режим TUN
closeAllConnections: Закрыть все соединения
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Küresel
profiles: Profiller
proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i
tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat
-2
View File
@@ -70,8 +70,6 @@ tray:
global: Глобаль
profiles: Профильләр
proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси
tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全局
profiles: 订阅
proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理
tunMode: TUN 模式
closeAllConnections: 关闭所有连接
-2
View File
@@ -70,8 +70,6 @@ tray:
global: 全域
profiles: 訂閱
proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
clash-verge-logging = { workspace = true }
chrono = { workspace = true }
log = { workspace = true }
@@ -11,6 +11,7 @@ mod chatgpt;
mod claude;
mod disney_plus;
mod gemini;
mod muse;
mod netflix;
mod prime_video;
mod spotify;
@@ -30,6 +31,7 @@ enum UnlockCheck {
ChatGptWeb,
Claude,
Gemini,
MuseAi,
YouTubePremium,
BahamutAnime,
Netflix,
@@ -46,6 +48,7 @@ impl UnlockCheck {
Self::ChatGptWeb,
Self::Claude,
Self::Gemini,
Self::MuseAi,
Self::YouTubePremium,
Self::BahamutAnime,
Self::Netflix,
@@ -66,6 +69,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
Self::Claude => claude::CLAUDE_NAME,
Self::Gemini => gemini::GEMINI_NAME,
Self::MuseAi => muse::MUSE_AI_NAME,
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
Self::Netflix => netflix::NETFLIX_NAME,
@@ -83,6 +87,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
Self::Claude => claude::check_claude(client).await,
Self::Gemini => gemini::check_gemini(client).await,
Self::MuseAi => muse::check_muse_ai(client).await,
Self::YouTubePremium => youtube::check_youtube_premium(client).await,
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
Self::Netflix => netflix::check_netflix(client).await,
@@ -0,0 +1,48 @@
use reqwest::Client;
use crate::utils::{classify_restricted_status, get_trace_location};
use super::UnlockItem;
pub(crate) const MUSE_AI_NAME: &str = "Muse AI";
const MUSE_URL: &str = "https://muse.ai";
const CLOUDFLARE_TRACE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
// The aymh ("Are You a Human") gateway answers plain HTTP requests with a
// generic Meta error page that always contains the Facebook logo asset.
const AYMH_ERROR_MARKERS: &[&str] = &["Sorry, something went wrong", "static.facebook.com/images/logos"];
pub(super) async fn check_muse_ai(client: &Client) -> UnlockItem {
// muse.ai itself is served by Vercel, so resolve the exit region through
// the generic Cloudflare trace endpoint instead of a service-specific one.
let region = get_trace_location(client, CLOUDFLARE_TRACE_URL)
.await
.map(|code| code.trim().to_ascii_uppercase())
.map(|code| UnlockItem::region_label(&code));
let Ok(response) = client.get(MUSE_URL).send().await else {
return UnlockItem::checked(MUSE_AI_NAME, "Failed (Network Connection)", region);
};
let status = response.status();
let landed_on_aymh = response.url().host_str() == Some("auth.muse.ai");
let body = response.text().await.unwrap_or_default();
// Every region is redirected to the aymh anti-bot gateway before the app
// loads, and a non-browser HTTP client cannot finish the JavaScript
// challenge. It returns the Meta error page, so reaching this point only
// proves the exit can connect to Muse's auth infrastructure; availability
// cannot be verified over plain HTTP and is reported as `Risky`.
let behind_anti_bot = landed_on_aymh || AYMH_ERROR_MARKERS.iter().any(|marker| body.contains(marker));
let result = if behind_anti_bot {
"Risky"
} else if status.is_success() {
"Yes"
} else {
classify_restricted_status(status).unwrap_or("Failed")
};
UnlockItem::checked(MUSE_AI_NAME, result, region)
}
+1 -1
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::utils::{country_code_to_emoji, get_local_date_string};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct UnlockItem {
pub name: String,
pub status: String,
+23 -31
View File
@@ -1,3 +1,15 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
@@ -5,20 +17,13 @@ use std::sync::{
use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix;
#[cfg(windows)]
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,
}
/// Serializes signal-triggered cleanups: a signal arriving while a quit
/// attempt is still in flight is dropped instead of overlapping it.
pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool,
}
@@ -36,17 +41,18 @@ impl ShutdownLatch {
.is_ok()
}
fn finish(&self, outcome: ShutdownOutcome) {
if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
fn finish(&self) {
self.is_cleaning_up.store(false, Ordering::Release);
}
}
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt),
@@ -61,36 +67,22 @@ where
}
});
#[cfg(unix)]
unix::register(f);
#[cfg(windows)]
windows::register(f);
}
#[cfg(test)]
mod tests {
use super::{ShutdownLatch, ShutdownOutcome};
use super::ShutdownLatch;
#[test]
fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
fn finished_attempt_releases_latch_for_the_next_signal() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
assert!(!latch.try_begin());
latch.finish(ShutdownOutcome::Canceled);
latch.finish();
assert!(latch.try_begin());
}
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
}
+4 -3
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
use crate::{RUNTIME, SHUTDOWN_LATCH};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
@@ -60,7 +60,8 @@ where
}
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
f().await;
SHUTDOWN_LATCH.finish();
}
});
} else {
-83
View File
@@ -1,83 +0,0 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -5,6 +5,7 @@ edition = "2024"
rust-version = "1.91"
[dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true }
@@ -14,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.190"
libc = "0.2.191"
[target.'cfg(windows)'.dependencies]
deelevate = { workspace = true }
@@ -1,53 +1,46 @@
use parking_lot::RwLock;
use serde::Serialize;
use tauri::{AppHandle, Runtime, State, command};
use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error};
use tauri_plugin_clipboard_manager::ClipboardExt as _;
use crate::Platform;
#[derive(Serialize)]
#[derive(Serialize, specta::Type)]
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 {
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,
system_name: platform.sysinfo.system_name.clone(),
system_version: platform.sysinfo.system_version.clone(),
}
}
}
#[command]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, Error> {
#[specta::specta]
pub fn get_system_info(state: State<'_, RwLock<Platform>>) -> Result<SystemInfo, String> {
let platform = state.inner().read();
Ok(SystemInfo::from(platform.clone()))
Ok(SystemInfo::from(&*platform))
}
/// 获取应用的运行时间(毫秒)
#[command]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, Error> {
#[specta::specta]
pub fn get_app_uptime(state: State<'_, RwLock<Platform>>) -> Result<u128, String> {
Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis())
}
#[command]
#[specta::specta]
pub fn export_diagnostic_info<R: Runtime>(
app_handle: AppHandle<R>,
state: State<'_, RwLock<Platform>>,
) -> Result<(), Error> {
) -> Result<(), String> {
let info = state.inner().read().to_string();
let clipboard = app_handle.clipboard();
clipboard.write_text(info)
clipboard.write_text(info).map_err(|error| error.to_string())
}
+77
View File
@@ -1,3 +1,80 @@
## v2.5.10
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复全局模式缺少 `GLOBAL` 策略组,无法单独选择全局出口
- 修复服务模式下,SSH 节点的 `private-key` 使用私钥文件时内核无法启动
- 修复服务模式下,内核更新的 GEO 数据库不会同步回应用目录,导致使用新分类的规则无法通过校验
- 修复订阅的 DNS 配置变化后,「DNS 覆写」实际已关闭但开关仍显示开启且没有提示
**🖥️ Windows**
- Windows:修复安装程序重新安装或覆盖时未提示退出运行中应用,导致卸载报错或覆盖失败
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
</details>
## v2.5.9
- **Mihomo(Meta) 内核升级至 v1.19.33**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复规则扩展包含空列表项时,「编辑规则」界面报错的问题
- 修复 重启应用后定时自动备份不执行的问题
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
- 修复 代理 在全局模式下无法展开节点组
- 修复 自定义 CSS 无法修改编辑器字体和字号的问题
- 修复 WebDAV 设置清空后无法保存
**🍎 macOS**
- macOS:修复 Bigsur 上首页测速和流量图无法显示的问题
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
- 新增「IP 信息」自动刷新开关,关闭后仍可手动刷新
- 新增 Muse AI 解锁检测,针对 Muse AI 特有的 Cloudflare 人机验证增加「Risky」状态标记
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details>
## v2.5.8
<details>
+157
View File
@@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
import tseslint from 'typescript-eslint'
const ipcBoundaryRules = ({
allowCommands = false,
allowInvoke = false,
allowEvents = false,
} = {}) => {
const paths = [
...(!allowInvoke
? [
{
name: '@tauri-apps/api/core',
importNames: ['invoke'],
allowTypeImports: true,
message: 'Invoke backend commands through services/cmds.',
},
]
: []),
...(!allowEvents
? [
{
name: '@tauri-apps/api/event',
importNames: ['listen', 'emit', 'emitTo', 'once'],
allowTypeImports: true,
message:
'The event bus owns Tauri event subscriptions and emissions.',
},
]
: []),
]
const patterns = allowCommands
? []
: [
{
regex: '(^|/)bindings(\\.ts)?$',
importNames: ['commands'],
allowTypeImports: true,
message:
'Only services/cmds may import generated command implementations.',
},
]
const modules = [
...(!allowInvoke ? ['@tauri-apps/api/core'] : []),
...(!allowEvents ? ['@tauri-apps/api/event'] : []),
]
const syntax = modules.flatMap((name) => [
{
selector: `ImportExpression[source.value='${name}']`,
message:
'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.',
},
{
selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`,
message:
'Use the owned IPC service boundary instead of requiring Tauri APIs.',
},
{
selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`,
message: 'Use the owned IPC service boundary for Tauri APIs.',
},
])
if (!allowCommands) {
syntax.push(
{
selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
"CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]",
message:
'Only services/cmds may load generated command implementations.',
},
{
selector:
'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]',
message:
'Only services/cmds may load generated command implementations.',
},
)
}
return {
'no-restricted-imports': ['error', { paths, patterns }],
'no-restricted-syntax': ['error', ...syntax],
}
}
export default defineConfig([
pluginESx.configs['flat/restrict-to-es2022'],
{
@@ -43,6 +129,7 @@ export default defineConfig([
'eslint.config.ts',
`vite.config.mts`,
'scripts/*.mjs',
'scripts-workflow/*.mjs',
'tests/*.ts',
'src/polyfills/*.js',
'tests/*.mjs',
@@ -152,6 +239,13 @@ export default defineConfig([
},
},
},
{
files: ['scripts-workflow/*.mjs'],
languageOptions: {
globals: globals.node,
},
rules: { 'es-x/no-object-groupby': 'off' },
},
{
files: ['scripts/perf/*.{mjs,mts}'],
languageOptions: {
@@ -162,4 +256,67 @@ export default defineConfig([
},
},
},
{
files: ['scripts/react/*.{mjs,mts,ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.json',
},
},
settings: {
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: './scripts/react/tsconfig.json',
}),
],
},
},
{
files: ['scripts/react/*.{mjs,mts}'],
languageOptions: {
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.node.json',
},
},
},
{
files: ['scripts/react/{boundaries,material,scene}.tsx'],
// The isolated fixtures use full reloads and preserve aliased Hook names.
rules: {
'react-refresh/only-export-components': 'off',
'@eslint-react/no-unnecessary-use-prefix': 'off',
},
},
{
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(),
},
{
files: ['src/services/cmds.ts'],
rules: ipcBoundaryRules({ allowCommands: true }),
},
{
files: ['src/services/bindings.ts'],
rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }),
},
{
files: ['src/services/bus/**/*.{ts,tsx}'],
rules: ipcBoundaryRules({ allowEvents: true }),
},
{
files: [
'src/components/proxy/proxy-chain.tsx',
'src/components/proxy/proxy-groups-chain.tsx',
'src/pages/proxies.tsx',
],
rules: {
'@typescript-eslint/no-floating-promises': [
'error',
{ ignoreVoid: false },
],
},
},
])
+11 -2
View File
@@ -1,6 +1,15 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/polyfills/*.js!"],
"entry": [
"src/polyfills/*.js!",
"scripts/check-contract-changes.mjs!",
"scripts/react/scene.tsx!",
"scripts/react/profiler.ts!",
"scripts/react/boundaries.tsx!",
"scripts/react/material.tsx!",
"scripts/react/yaml.ts!",
"scripts/react/wdyr.ts!"
],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [
"files",
@@ -18,7 +27,7 @@
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"],
"src/services/contract.ts": ["exports", "types"]
"src/services/bindings.ts": ["types"]
},
"cycles": {
"dynamicImports": true
+30 -23
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.9",
"version": "2.6.0",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -14,7 +14,7 @@
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
"web:build": "tsrs -p . --noEmit && vite build",
"web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs",
@@ -32,7 +32,7 @@
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"typecheck": "tsrs -p . --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"knip:check": "knip",
@@ -40,7 +40,11 @@
"perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs"
"perf:test": "node --test scripts/perf/report.test.mjs",
"dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev",
"react:ab": "node scripts/react/ab.mjs",
"dev:wdyr": "cross-env CVR_REACT_DIAGNOSTICS=wdyr node scripts/dev.mjs dev",
"audit:react": "node scripts/react/doctor.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
@@ -54,13 +58,13 @@
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.1",
"@tauri-apps/plugin-clipboard-manager": "2.4.0",
"@tauri-apps/plugin-dialog": "2.8.0",
"@tauri-apps/plugin-fs": "2.6.0",
"@tauri-apps/plugin-http": "2.8.0",
"@tauri-apps/plugin-opener": "2.7.0",
"@tauri-apps/plugin-updater": "2.13.1",
"@tauri-apps/api": "2.12.4",
"@tauri-apps/plugin-clipboard-manager": "2.5.0",
"@tauri-apps/plugin-dialog": "2.8.3",
"@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2",
"@tauri-apps/plugin-updater": "2.14.1",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
@@ -75,16 +79,15 @@
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.15",
"react-i18next": "17.1.9",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
@@ -95,15 +98,15 @@
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.1",
"@tauri-apps/cli": "2.12.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6",
"@types/node": "^26.0.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"@wdio/tauri-service": "1.5.0",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"cli-color": "^2.0.4",
@@ -118,23 +121,27 @@
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"glob": "^14.0.0",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.39.0",
"knip": "6.44.1",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"tsrs": "0.20.0-ts7.1.0-beta",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1",
"webdriverio": "10.0.0",
"ws": "8.22.0"
"webdriverio": "10.1.0",
"ws": "8.22.1",
"playwright": "1.64.1",
"@welldone-software/why-did-you-render": "10.0.1",
"react-doctor": "0.9.21"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
@@ -146,5 +153,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.10.1"
"packageManager": "pnpm@12.16.1"
}
+1611 -956
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -10,3 +10,11 @@ allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- dayjs@1.11.21
- "@tsrs/darwin-arm64@0.20.0-ts7.1.0-beta"
- "@tsrs/darwin-x64@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-arm64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-gnu@0.20.0-ts7.1.0-beta"
- "@tsrs/linux-x64-musl@0.20.0-ts7.1.0-beta"
- "@tsrs/win32-x64@0.20.0-ts7.1.0-beta"
- tsrs@0.20.0-ts7.1.0-beta
+18
View File
@@ -45,6 +45,17 @@
"matchManagers": ["npm"],
"groupName": "npm dependencies"
},
{
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
"matchPackageNames": [
"@tauri-apps/**",
"@wdio/tauri-service",
"tauri",
"tauri-*"
],
"groupName": "tauri",
"addLabels": ["tauri"]
},
{
"description": "Group all GitHub Actions updates into a single PR",
"matchManagers": ["github-actions"],
@@ -68,6 +79,13 @@
"matchPackageNames": ["typescript"],
"allowedVersions": "<7.0.0 || >=7.1.0"
},
{
"description": "Review tsrs bumps alone; the version encodes the pinned TypeScript release",
"matchPackageNames": ["tsrs", "/^@tsrs//"],
"groupName": "tsrs",
"rangeStrategy": "pin",
"automerge": false
},
{
"description": "Prevent project from supply chain attack",
"matchDatasources": ["npm"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.99.0"
channel = "1.99.1"
components = ["rustfmt", "clippy"]
+4 -1
View File
@@ -41,7 +41,10 @@ fi
tmpfile=$(mktemp)
{
cat "$CHANGELOG"
node -e '
const fs = require("node:fs");
process.stdout.write(fs.readFileSync(process.argv[1], "utf8").replace(/<!--[\s\S]*?-->/g, "").trimStart());
' "$CHANGELOG"
echo
echo ""
if [ -f "$HISTORY" ]; then
+803
View File
@@ -0,0 +1,803 @@
import { execFileSync } from 'node:child_process'
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
const env = process.env
const repository = env.GITHUB_REPOSITORY
const refreshLabel = 'Refresh this PR from latest dev'
const publishLabel = 'Publish this approved release'
const minorLabel = 'Use the next minor version'
const checked = (body, label = refreshLabel) =>
(body || '')
.split('\n')
.some((line) => line.toLowerCase() === `- [x] ${label}`.toLowerCase())
const versionPattern = '(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)'
const rcPattern = new RegExp(`^v${versionPattern}-rc(?:\\.(0|[1-9][0-9]*))?$`)
const stablePattern = new RegExp(`^v${versionPattern}$`)
const versionFiles = [
'package.json',
'src-tauri/tauri.conf.json',
'src-tauri/Cargo.toml',
'Cargo.lock',
]
const read = (path) => readFileSync(path, 'utf8')
const json = (path) => JSON.parse(read(path))
const run = (command, args, options = {}) =>
execFileSync(command, args, { encoding: 'utf8', ...options }).trim()
const git = (...args) => run('git', args)
const gitFile = (sha, path) =>
execFileSync('git', ['show', `${sha}:${path}`], { encoding: 'utf8' })
const isRc = (tag) => tag.match(rcPattern)?.[0] === tag
const isRelease = (tag) => isRc(tag) || stablePattern.test(tag)
const titleFor = (tag) => `${isRc(tag) ? 'Pre-Release' : 'Release'}: ${tag}`
const branchTag = (branch, prefix = 'release/') =>
branch.startsWith(prefix) && isRelease(branch.slice(prefix.length))
? branch.slice(prefix.length)
: undefined
const latest = (tags) =>
tags.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1)
const summary = (text) => appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`)
const endpoint = (path) =>
path === 'graphql' ? path : `repos/${repository}/${path}`
const list = (path) =>
JSON.parse(run('gh', ['api', '--paginate', '--slurp', endpoint(path)])).flat()
function api(path, body, method = 'POST') {
const response = run(
'gh',
['api', '--method', method, endpoint(path), '--input', '-'],
{
input: JSON.stringify(body),
},
)
const result = response ? JSON.parse(response) : null
if (result?.errors) throw new Error(JSON.stringify(result.errors))
return result
}
const get = (path) => JSON.parse(run('gh', ['api', endpoint(path)]))
const canWrite = (login) =>
['write', 'maintain', 'admin'].includes(
get(`collaborators/${login}/permission`).permission,
)
function output(values) {
appendFileSync(
env.GITHUB_OUTPUT,
Object.entries(values)
.map(([key, value]) => `${key}=${value}\n`)
.join(''),
)
}
function versionPatternFor(path) {
if (path.endsWith('.json')) return /("version"\s*:\s*")[^"]+/
return path === 'Cargo.lock'
? /(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = ")[^"]+/
: /(^version\s*=\s*")[^"]+/m
}
function validateVersions(tag, sha) {
for (const path of versionFiles) {
const content = sha ? gitFile(sha, path) : read(path)
const version = path.endsWith('.json')
? JSON.parse(content).version
: content.match(versionPatternFor(path))?.[0].split('"').at(-1)
if (`v${version}` !== tag)
throw new Error(`${path} version does not match ${tag}.`)
}
}
function validateVersionCommit(tag, sha) {
validateVersions(tag, sha)
const commit = get(`commits/${sha}`)
if (!commit.commit.verification.verified || commit.parents.length !== 1)
throw new Error('The version commit must be signed and have one parent.')
const title = commit.commit.message.split('\n')[0]
if (title.replace(/ \(#\d+\)$/, '') !== titleFor(tag))
throw new Error('The final version commit has an unexpected title.')
const parent = commit.parents[0].sha
const changed = git('diff', '--name-only', parent, sha)
.split('\n')
.filter(Boolean)
if (
changed.some((path) => !versionFiles.includes(path)) ||
git('diff', '--summary', parent, sha)
)
throw new Error(
'The version commit changes files outside the four version files.',
)
for (const path of versionFiles) {
const before = gitFile(parent, path)
const after = gitFile(sha, path)
if (
before.replace(versionPatternFor(path), '$1VERSION') !==
after.replace(versionPatternFor(path), '$1VERSION')
)
throw new Error(`${path} contains changes other than its version.`)
}
}
function remoteRefs() {
return git('ls-remote', '--heads', '--tags', '--refs', 'origin')
.split('\n')
.filter(Boolean)
.map((line) => line.split('\t'))
}
function fetchBranches() {
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/dev:refs/remotes/origin/dev',
'+refs/heads/main:refs/remotes/origin/main',
)
}
function discardClosedCandidate(branch, base, prefix) {
const candidates = list(
`pulls?state=all&base=${base}&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
const latest = candidates[0]
if (!latest || candidates.some((pr) => pr.state === 'open'))
throw new Error(
`Branch ${branch} is reserved by an active or unknown candidate.`,
)
const pr = get(`pulls/${latest.number}`)
requireBotPr(pr, base, prefix)
if (pr.state !== 'closed' || pr.merged)
throw new Error(
`Close the unmerged candidate before retrying: ${pr.html_url}`,
)
updateRefs(pr.base.repo.node_id, [
{
name: `refs/heads/${branch}`,
beforeOid: pr.head.sha,
afterOid: '0'.repeat(40),
},
])
summary(`Released the unused branch from closed PR: ${pr.html_url}`)
}
function prepare() {
let refresh
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
if (checked(event.changes?.body?.from) || !checked(event.pull_request.body))
return
if (!canWrite(event.sender.login))
throw new Error('Refreshing a version PR requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open' || !checked(refresh.body)) return
requireBotPr(refresh, 'dev', 'release/')
}
fetchBranches()
git('checkout', '--detach', 'origin/dev')
const channel = refresh
? isRc(branchTag(refresh.head.ref))
? 'rc'
: 'stable'
: env.RELEASE_CHANNEL || 'rc'
if (!['rc', 'stable'].includes(channel))
throw new Error('Unknown release channel.')
const requested = refresh
? branchTag(refresh.head.ref)
: env.RELEASE_TAG || ''
if (
requested &&
!(channel === 'rc' ? isRc(requested) : stablePattern.test(requested))
)
throw new Error(
channel === 'rc'
? 'Expected vX.Y.Z-rc or vX.Y.Z-rc.N.'
: 'Expected vX.Y.Z.',
)
const existing =
!refresh &&
list('pulls?state=open&base=dev&per_page=100').find(
(pr) =>
pr.head.repo?.full_name === repository &&
pr.user.login === 'github-actions[bot]' &&
branchTag(pr.head.ref) &&
isRc(branchTag(pr.head.ref)) === (channel === 'rc') &&
(!requested || branchTag(pr.head.ref) === requested),
)
if (existing) {
api(`issues/${existing.number}/labels`, { labels: ['release'] })
output({ existing_pr: existing.html_url })
summary(
`Existing version PR: ${existing.html_url}\nClose it before preparing a replacement; its candidate is unchanged.`,
)
return
}
const base = (requested || `v${json('package.json').version}`).split(
/[-+]/,
)[0]
if (!stablePattern.test(base))
throw new Error(`Invalid base version: ${base}`)
const refs = remoteRefs().map(([, ref]) => ref)
const tags = refs
.filter((ref) => ref.startsWith('refs/tags/'))
.map((ref) => ref.slice(10))
const reserved = refs
.filter((ref) => ref.startsWith('refs/heads/'))
.filter((ref) => !refresh || ref !== `refs/heads/${refresh.head.ref}`)
.map(
(ref) => branchTag(ref.slice(11)) || branchTag(ref.slice(11), 'publish/'),
)
.filter(Boolean)
const sameBase = (tag) => isRc(tag) && tag.startsWith(`${base}-rc`)
const occupied = [...tags, ...reserved]
const last = latest(occupied.filter(sameBase))
const tag =
requested ||
(channel === 'stable'
? base
: last
? `${base}-rc.${BigInt(last.match(rcPattern)[4] ?? 0) + 1n}`
: `${base}-rc`)
if (tags.includes(tag)) throw new Error(`Tag ${tag} already exists.`)
for (const [prefix, target] of [
['release/', 'dev'],
['publish/', 'main'],
]) {
const branch = `${prefix}${tag}`
if (refs.includes(`refs/heads/${branch}`) && branch !== refresh?.head.ref)
discardClosedCandidate(branch, target, prefix)
}
const sha = git('rev-parse', 'HEAD')
const body = releaseBody(tag, sha, tags, false)
summary(body)
if (
remoteRefs().some(
([, ref]) =>
ref === `refs/tags/${tag}` || ref === `refs/heads/publish/${tag}`,
)
)
throw new Error(`Tag ${tag} already exists or is reserved for publication.`)
if (json('package.json').version !== tag.slice(1))
run('node', ['scripts/release-version.mjs', tag])
validateVersions(tag)
const title = titleFor(tag)
const branch = refresh
? `release-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: `release/${tag}`
writeVersionPr({ sha, body, refresh, title, branch, files: versionFiles })
}
function releaseBody(tag, sha, tags, publishing) {
const previous =
(isRc(tag) &&
latest(
tags.filter(
(value) =>
isRc(value) &&
value.startsWith(`${tag.split('-')[0]}-rc`) &&
value !== tag,
),
)) ||
latest(tags.filter((value) => stablePattern.test(value) && value !== tag))
const url = `${env.GITHUB_SERVER_URL}/${repository}`
let range = sha
let comparison = 'No previous release tag'
if (previous) {
git('fetch', '--no-tags', 'origin', `refs/tags/${previous}`)
range = `${git('rev-parse', 'FETCH_HEAD^{commit}')}..${sha}`
comparison = `Changes since **${previous}** · [Compare changes](${url}/compare/${previous}...${sha})`
}
comparison += ` · **${git('rev-list', '--count', range)} commits**`
const commits = git('log', '--format=%h %s', range)
const groups = Object.groupBy(commits ? commits.split('\n') : [], (line) => {
if (/^\S+ chore\(deps\)!?: /i.test(line)) return 'deps'
const type = line.match(/^\S+ (\w+)(?:\([^)]*\))?!?: /)?.[1]?.toLowerCase()
return type === 'doc' ? 'docs' : type || 'other'
})
const labels = {
fix: 'Fixes',
feat: 'Features',
perf: 'Performance',
revert: 'Reverts',
refactor: 'Refactoring',
build: 'Build',
deps: 'Dependencies',
ci: 'CI',
docs: 'Documentation',
test: 'Tests',
chore: 'Maintenance',
style: 'Style',
other: 'Other',
}
const names = {
macos: 'macOS',
windows: 'Windows',
ui: 'UI',
ci: 'CI',
tun: 'TUN',
}
const order = Object.keys(labels)
const rank = (type) =>
order.includes(type) ? order.indexOf(type) : order.length
const scope =
Object.entries(groups)
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([type, lines]) => {
const entries = lines.map((line) => {
const hash = line.slice(0, line.indexOf(' '))
const title = line
.slice(hash.length + 1)
.replace(/\s*\((?:[\w.-]+\/[\w.-]+)?#\d+\)/g, '')
.replace(/(?:[\w.-]+\/[\w.-]+)?#\d+\b/g, '')
.replace(/\s{2,}/g, ' ')
.trim()
.replace(
/^\w+(?:\(([^)]*)\))?(!)?:\s*/,
(_, scope, breaking) =>
`${breaking ? 'Breaking: ' : ''}${scope ? `${Object.hasOwn(names, scope) ? names[scope] : scope}: ` : ''}`,
)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replace(/[\\`*_[\]]/g, '\\$&')
return `- ${title} [#${hash}](${url}/commit/${hash})`
})
return `### ${Object.hasOwn(labels, type) ? labels[type] : type} · ${lines.length}\n\n${entries.join('\n')}`
})
.join('\n\n') || 'No new commits.'
return `## ${titleFor(tag)}
@clash-verge-rev/release
Source: \`dev\` · [${sha}](${url}/commit/${sha}) \n${comparison}
${
publishing
? `<!-- release-source:${sha} -->
After approval, select the checkbox below to publish.
Do not use the normal Squash or Rebase merge buttons.
- [ ] ${publishLabel}`
: `
- [ ] ${refreshLabel}
Refreshing requires a new approval.`
}
## Changes
${scope}
`
}
function writeVersionPr({ sha, body, refresh, title, branch, files }) {
// Create-only ref and expectedHeadOid prevent replacing another candidate.
api('git/refs', { ref: `refs/heads/${branch}`, sha })
let pr
try {
const commit = api('graphql', {
query: `mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`,
variables: {
input: {
branch: { repositoryNameWithOwner: repository, branchName: branch },
expectedHeadOid: sha,
message: { headline: title },
fileChanges: {
additions: files.map((path) => ({
path,
contents: readFileSync(path).toString('base64'),
})),
},
},
},
})
if (!commit.data?.createCommitOnBranch?.commit?.oid)
throw new Error('Signed version commit failed.')
if (refresh) {
const current = get(`pulls/${refresh.number}`)
if (
current.state !== 'open' ||
current.head.sha !== refresh.head.sha ||
current.base.ref !== refresh.base.ref ||
current.body !== refresh.body
)
throw new Error(
'The version pull request changed while preparing the update.',
)
const oid = commit.data.createCommitOnBranch.commit.oid
updateRefs(refresh.base.repo.node_id, [
{
name: `refs/heads/${refresh.head.ref}`,
beforeOid: refresh.head.sha,
afterOid: oid,
force: true,
},
{
name: `refs/heads/${branch}`,
beforeOid: oid,
afterOid: '0'.repeat(40),
},
])
for (const review of list(
`pulls/${refresh.number}/reviews?per_page=100`,
)) {
if (review.state === 'APPROVED')
api(
`pulls/${refresh.number}/reviews/${review.id}/dismissals`,
{
message:
'The version PR was refreshed from dev; please review again.',
},
'PUT',
)
}
pr = api(`pulls/${refresh.number}`, { title, body }, 'PATCH')
} else {
pr = api('pulls', {
head: branch,
base: 'dev',
title,
body,
})
}
} catch (error) {
if (refresh) {
try {
api(`git/refs/heads/${branch}`, {}, 'DELETE')
} catch {}
}
throw error
}
api(`issues/${pr.number}/labels`, { labels: ['release'] })
summary(`Version pull request: ${pr.html_url}`)
}
function updateRefs(repositoryId, refUpdates) {
api('graphql', {
query: `mutation($input: UpdateRefsInput!) { updateRefs(input: $input) { clientMutationId } }`,
variables: { input: { repositoryId, refUpdates } },
})
}
function requireBotPr(pr, base, prefix) {
const tag = branchTag(pr.head.ref, prefix)
if (
!tag ||
pr.base.ref !== base ||
pr.base.repo.full_name !== repository ||
pr.head.repo?.full_name !== repository ||
pr.user.login !== 'github-actions[bot]'
)
throw new Error(
'Expected a release PR created by this repository workflow.',
)
return tag
}
function publishPr() {
const event = json(env.GITHUB_EVENT_PATH)
const pr = get(`pulls/${event.pull_request.number}`)
const tag = requireBotPr(pr, 'dev', 'release/')
if (!pr.merged || pr.merge_commit_sha !== event.pull_request.merge_commit_sha)
throw new Error('Expected the final merged version commit on dev.')
const sha = pr.merge_commit_sha
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const branch = `publish/${tag}`
const existing = list(
`pulls?state=open&base=main&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)
if (existing.length) {
const publication = existing[0]
requireBotPr(publication, 'main', 'publish/')
if (publication.head.sha !== sha)
throw new Error(
'This tag is already reserved by a different publication snapshot.',
)
summary(`Existing publication PR: ${publication.html_url}`)
return
}
const refs = remoteRefs()
if (refs.some(([, ref]) => ref === `refs/tags/${tag}`))
throw new Error(`Tag ${tag} already exists.`)
git('merge-base', '--is-ancestor', 'origin/main', sha)
const head = refs.find(([, ref]) => ref === `refs/heads/${branch}`)?.[0]
if (head && head !== sha)
throw new Error('Publication branch already points to another commit.')
if (!head) api('git/refs', { ref: `refs/heads/${branch}`, sha })
const body = releaseBody(
tag,
sha,
refs
.filter(([, ref]) => ref.startsWith('refs/tags/'))
.map(([, ref]) => ref.slice(10)),
true,
)
const publication = api('pulls', {
head: branch,
base: 'main',
title: titleFor(tag),
body,
})
api(`issues/${publication.number}/labels`, { labels: ['release'] })
summary(`Publication PR: ${publication.html_url}\n\n${body}`)
}
function requireApproval(pr, sha) {
const reviews = new Map()
for (const review of list(`pulls/${pr.number}/reviews?per_page=100`)) {
if (['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state))
reviews.set(review.user.login, review)
}
let approved = false
for (const review of reviews.values()) {
if (!canWrite(review.user.login)) continue
if (review.state === 'CHANGES_REQUESTED')
throw new Error('A reviewer with write access has requested changes.')
if (review.state === 'APPROVED' && review.commit_id === sha) approved = true
}
if (!approved)
throw new Error(
'The current PR head needs approval from a reviewer with write access.',
)
}
function releaseOutput(tag, sha) {
output({
release_tag: tag,
source_sha: sha,
channel: isRc(tag) ? 'rc' : 'stable',
})
}
function publish() {
const event = json(env.GITHUB_EVENT_PATH)
const tag = requireBotPr(event.pull_request, 'main', 'publish/')
if (
checked(event.changes?.body?.from, publishLabel) ||
!checked(event.pull_request.body, publishLabel)
)
throw new Error('Publishing requires a new checkbox selection.')
if (!canWrite(event.sender.login))
throw new Error('Publishing requires write access.')
const sha = event.pull_request.head.sha
const pr = get(`pulls/${event.pull_request.number}`)
requireBotPr(pr, 'main', 'publish/')
if (
pr.head.sha !== sha ||
!checked(pr.body, publishLabel) ||
!pr.body.includes(`<!-- release-source:${sha} -->`)
)
throw new Error(
'The publication snapshot changed after the checkbox selection.',
)
fetchBranches()
git('fetch', '--no-tags', 'origin', sha)
git('merge-base', '--is-ancestor', sha, 'origin/dev')
validateVersionCommit(tag, sha)
const marker = `Release workflow run: ${env.GITHUB_RUN_ID}\nSource: ${sha}\nPR: ${pr.number}`
const existing = remoteRefs().find(([, ref]) => ref === `refs/tags/${tag}`)
if (existing) {
const ref = get(`git/ref/tags/${tag}`)
const object =
ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (
!object ||
object.object.type !== 'commit' ||
object.object.sha !== sha ||
object.message.trim() !== marker ||
Number(env.GITHUB_RUN_ATTEMPT) <= 1
)
throw new Error(
'An existing tag can only be recovered by its original release run.',
)
git('merge-base', '--is-ancestor', sha, 'origin/main')
summary(`Resuming ${tag} at ${sha} from this release run.`)
} else {
if (pr.state !== 'open' || pr.draft)
throw new Error('Expected an open, ready publication PR.')
requireApproval(pr, sha)
const main = git('rev-parse', 'origin/main')
git('merge-base', '--is-ancestor', main, sha)
git(
'-c',
'user.name=github-actions[bot]',
'-c',
'user.email=41898282+github-actions[bot]@users.noreply.github.com',
'tag',
'--annotate',
'--no-sign',
'--message',
marker,
tag,
sha,
)
// GraphQL updateRefs rejects annotated tag objects; Git preserves atomicity.
git(
'push',
'--atomic',
`--force-with-lease=refs/heads/main:${main}`,
`--force-with-lease=refs/heads/${pr.head.ref}:${sha}`,
`--force-with-lease=refs/tags/${tag}:`,
'origin',
`${sha}:refs/heads/main`,
`${sha}:refs/heads/${pr.head.ref}`,
`refs/tags/${tag}:refs/tags/${tag}`,
)
summary(`Fast-forwarded main and tagged ${tag} at approved commit ${sha}.`)
}
releaseOutput(tag, sha)
}
function validateTag() {
const tag = env.GITHUB_REF_NAME
if (!isRelease(tag)) throw new Error('Expected a stable or RC release tag.')
fetchBranches()
const sha = git('rev-parse', `${env.GITHUB_SHA}^{commit}`)
const ref = get(`git/ref/tags/${tag}`)
const object = ref.object.type === 'tag' && get(`git/tags/${ref.object.sha}`)
if (object && object.message.includes('Release workflow run:'))
throw new Error(
'Resume the original publication run for workflow-owned tags.',
)
git('fetch', '--no-tags', 'origin', `refs/tags/${tag}`)
if (git('rev-parse', 'FETCH_HEAD^{commit}') !== sha)
throw new Error('The tag moved after this run started.')
for (const branch of ['main', 'dev'])
git('merge-base', '--is-ancestor', sha, `origin/${branch}`)
validateVersions(tag, sha)
releaseOutput(tag, sha)
}
function prepareDraft() {
const tag = env.RELEASE_TAG
verifyTarget()
const result = run('gh', [
'api',
'--paginate',
endpoint('releases?per_page=100'),
'--jq',
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | {id, draft}`,
])
const release = result ? JSON.parse(result) : null
if (release && !release.draft)
throw new Error('This release is already public.')
const draft =
release ||
api('releases', {
tag_name: tag,
target_commitish: env.SOURCE_SHA,
name: `Clash Verge Rev ${tag}`,
body: 'Draft release, will be updated later.',
draft: true,
prerelease: isRc(tag),
})
output({ id: draft.id })
}
function verifyTarget(tag = env.RELEASE_TAG, sha = env.SOURCE_SHA) {
const ref = get(`git/ref/tags/${tag}`)
const target =
ref.object.type === 'tag'
? get(`git/tags/${ref.object.sha}`).object
: ref.object
if (target.type !== 'commit' || target.sha !== sha)
throw new Error('The release tag no longer points to the validated source.')
}
function nextDev() {
let refresh
let tag = env.RELEASE_TAG
let source = env.SOURCE_SHA
let minor = false
if (env.GITHUB_EVENT_NAME === 'pull_request_target') {
const event = json(env.GITHUB_EVENT_PATH)
const oldBody = event.changes?.body?.from
const body = event.pull_request.body
if (
checked(oldBody, minorLabel) === checked(body, minorLabel) &&
(checked(oldBody) || !checked(body))
)
return
if (!canWrite(event.sender.login))
throw new Error('Changing the next dev version requires write access.')
refresh = get(`pulls/${event.pull_request.number}`)
if (refresh.state !== 'open') return
tag = requireBotPr(refresh, 'dev', 'next-dev/')
if (
refresh.head.sha !== event.pull_request.head.sha ||
refresh.body !== body
)
throw new Error(
'The version update PR changed after the checkbox selection.',
)
minor = checked(body, minorLabel)
source = body.match(/<!-- next-dev-source:([a-f0-9]{40}) -->/)?.[1]
}
if (!stablePattern.test(tag) || !/^[a-f0-9]{40}$/.test(source))
throw new Error('The next dev version requires a stable release snapshot.')
verifyTarget(tag, source)
const release = get(`releases/tags/${tag}`)
if (release.draft || release.prerelease)
throw new Error('The stable release must be public before preparing dev.')
const branch = `next-dev/${tag}`
if (!refresh) {
const existing = list(
`pulls?state=all&base=dev&head=${repository.split('/')[0]}:${branch}&per_page=100`,
)[0]
if (existing) {
requireBotPr(existing, 'dev', 'next-dev/')
summary(`Existing dev version PR: ${existing.html_url}`)
return
}
}
fetchBranches()
git('fetch', '--no-tags', 'origin', source)
git('merge-base', '--is-ancestor', source, 'origin/main')
git('merge-base', '--is-ancestor', source, 'origin/dev')
git('checkout', '--detach', 'origin/dev')
const sha = git('rev-parse', 'HEAD')
validateVersions(tag, source)
validateVersions(tag, sha)
if (read('Changelog.md') !== gitFile(source, 'Changelog.md'))
throw new Error(
'Changelog.md changed after this release. Preserve the unreleased entries and prepare the dev update manually.',
)
if (
!read('Changelog.md').split('\n').includes(`## ${tag}`) ||
read('docs/Changelog.history.md').split('\n').includes(`## ${tag}`)
)
throw new Error('The released changelog is missing or already archived.')
const [major, minorVersion, patch] = tag.slice(1).split('.').map(BigInt)
const patchTag = `v${major}.${minorVersion}.${patch + 1n}`
const minorTag = `v${major}.${minorVersion + 1n}.0`
const nextTag = minor ? minorTag : patchTag
run('pnpm', ['release-version', nextTag])
run('bash', ['scripts-workflow/bump_changelog.sh'])
const changelog = read('Changelog.md')
if (!changelog.includes('## v(Version Goes Here)'))
throw new Error('The changelog template has no version placeholder.')
writeFileSync(
'Changelog.md',
changelog.replace('## v(Version Goes Here)', `## ${nextTag}`),
)
validateVersions(nextTag)
const title = `Dev: ${nextTag}`
const body = `## ${title}
After [${tag}](${env.GITHUB_SERVER_URL}/${repository}/releases/tag/${tag}).
<!-- next-dev-source:${source} -->
Patch: \`${patchTag}\` · Minor: \`${minorTag}\`
- [${minor ? 'x' : ' '}] ${minorLabel}
- [ ] ${refreshLabel}
Changing the version or refreshing requires a new approval.
`
writeVersionPr({
sha,
body,
refresh,
title,
branch: refresh
? `next-dev-refresh/${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
: branch,
files: [...versionFiles, 'Changelog.md', 'docs/Changelog.history.md'],
})
}
const commands = {
prepare,
'next-dev': nextDev,
'publish-pr': publishPr,
publish,
'validate-tag': validateTag,
'prepare-draft': prepareDraft,
'verify-target': verifyTarget,
}
const command = commands[process.argv[2]]
if (!command) throw new Error('Unknown release command.')
command()
+25 -75
View File
@@ -1,84 +1,34 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
import { ESLint } from 'eslint'
const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src')
const tauriEventImport = (name) =>
new RegExp(
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
const gates = process.argv.slice(2)
if (
!gates.length ||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
let violations = 0
for (const gate of selected) {
const rule = GATES[gate]
if (!rule) {
console.error(`unknown gate: ${gate}`)
process.exit(2)
}
for (const file of walk(SRC)) {
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
if (rule.allowed(relative)) continue
const lines = fs.readFileSync(file, 'utf8').split('\n')
lines.forEach((line, index) => {
if (rule.patterns.some((pattern) => pattern.test(line))) {
violations += 1
console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
)
}
})
}
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
const results = await eslint.lintFiles(['src'])
const boundaryRules = new Set([
'no-restricted-imports',
'no-restricted-syntax',
'@typescript-eslint/no-floating-promises',
])
const violations = results.flatMap((result) =>
result.messages
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
.map((message) => ({ file: result.filePath, ...message })),
)
for (const violation of violations) {
console.error(
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
)
}
if (violations > 0) {
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
if (violations.length) process.exitCode = 1
else console.log('[arch] no violations')
+69
View File
@@ -0,0 +1,69 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '..')
const git = (...args) =>
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
const { contract } = JSON.parse(
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
)
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
const args = process.argv.slice(2)
if (args.length !== 2 || args[0] !== '--push') {
console.error('usage: check-contract-changes.mjs --push <remote>')
process.exit(2)
}
const hasChanges = (...range) =>
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
const assertCleanInputs = (...range) => {
if (
hasChanges(...range) ||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
) {
throw new Error(
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
)
}
}
let relevant = false
const head = git('rev-parse', 'HEAD')
for (const line of fs
.readFileSync(0, 'utf8')
.trim()
.split('\n')
.filter(Boolean)) {
const fields = line.trim().split(/\s+/)
if (
fields.length !== 4 ||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
) {
throw new Error('Invalid pre-push ref update')
}
const [, local, , remote] = fields
if (/^0+$/.test(local) || local === remote) continue
// A new remote branch has no upstream; use its fork point with the remote default branch.
const base = /^0+$/.test(remote)
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
: remote
if (!hasChanges(base, local)) continue
const commit = git('rev-parse', `${local}^{commit}`)
if (commit !== head)
throw new Error(
'Check out the pushed commit before validating its changed IPC contract.',
)
assertCleanInputs('HEAD')
relevant = true
}
if (relevant) {
execFileSync(
process.execPath,
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
{
cwd: ROOT,
stdio: 'inherit',
},
)
} else {
console.log('[contract] inputs unchanged; skipping Rust generation')
}
+23
View File
@@ -0,0 +1,23 @@
{
"contract": [
"src-tauri/**/*.rs",
"crates/**/*.rs",
"Cargo.toml",
"Cargo.lock",
"src-tauri/Cargo.toml",
"crates/**/Cargo.toml",
"rust-toolchain.toml",
"src-tauri/tauri.conf*.json",
"src/services/bindings.ts",
"scripts/gen-contract.mjs",
"scripts/check-contract-changes.mjs",
"biome.json",
"biome.jsonc",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"scripts/contract-inputs.json",
".github/workflows/contract-parity.yml",
".husky/pre-push"
]
}
+46 -82
View File
@@ -1,91 +1,55 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
/**
* Regenerates src/services/contract.ts from the backend wire contract.
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
* src-tauri, which serializes the same enum variants and event payloads the
* app emits at runtime. CI reruns this and fails on a non-empty diff.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const OUT = path.join(ROOT, 'src/services/contract.ts')
const contract = JSON.parse(
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
const args = process.argv.slice(2)
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
console.error('usage: gen-contract.mjs [--check]')
process.exit(2)
}
const check = args[0] === '--check'
const temporary = check
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
: null
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
try {
execFileSync(
'cargo',
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
{
cwd: path.join(ROOT, 'src-tauri'),
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
},
),
)
if (contract.version !== 1) {
throw new Error(`unsupported wire contract version: ${contract.version}`)
}
/** TS type per event, derived from the serialized sample payload's JSON shape. */
const payloadType = (name, sample) => {
if (sample === null) return 'null'
if (typeof sample === 'string') return 'string'
if (Array.isArray(sample)) {
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
throw new Error(`unexpected array payload for ${name}`)
[
'run',
'-p',
'clash-verge',
'--example',
'frontend-contract',
'--locked',
'-q',
...(check ? ['--', output] : []),
],
{ cwd: ROOT, stdio: 'inherit' },
)
const formatted = execFileSync(
process.execPath,
[
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
'format',
'--stdin-file-path',
BINDINGS,
],
{ cwd: ROOT, input: fs.readFileSync(output) },
)
if (check) {
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
console.error(
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
)
process.exitCode = 1
}
} else {
fs.writeFileSync(BINDINGS, formatted)
}
if (typeof sample === 'object') {
const keys = Object.keys(sample)
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
if (name === 'verge://run-state-changed') return 'RunState'
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
}
throw new Error(`unexpected payload for ${name}`)
} finally {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
}
const needsRunState = contract.events.some(
(e) => e.name === 'verge://run-state-changed',
)
if (!needsRunState)
throw new Error('run-state-changed event missing from contract')
const lines = []
lines.push(
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
)
lines.push('// Regen: pnpm gen:contract')
lines.push('')
lines.push("import type { RunState } from './cmds'")
lines.push('')
lines.push(
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
)
lines.push('export type NoticeStatus =')
for (const status of contract.noticeStatuses) {
lines.push(` | ${JSON.stringify(status)}`)
}
lines.push('')
lines.push('/** Event name → payload type for every backend-emitted event. */')
lines.push('export interface VergeEventPayloads {')
for (const event of contract.events) {
const key = JSON.stringify(event.name)
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
}
lines.push('}')
lines.push('')
const content = `${lines.join('\n')}\n`
fs.writeFileSync(OUT, content)
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
{ cwd: ROOT, stdio: 'ignore' },
)
console.log(
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
)
+5 -4
View File
@@ -434,12 +434,13 @@ for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
devicePixelRatio,
])
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
const driverScale = run.viewport.driver.width / 1000
assert(
run.viewport.driver.width / viewport[2] === 1000 &&
run.viewport.driver.height / viewport[2] === 700 &&
viewport[0] === 1000 &&
viewport[0] === 1000 &&
viewport[1] > 0 &&
viewport[1] <= 700,
viewport[1] <= 700 &&
(driverScale === 1 || driverScale === viewport[2]) &&
run.viewport.driver.height / driverScale === 700,
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
)
assert(
+6 -7
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
}
if (!META_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta unsupported platform "${platform}-${arch}"`)
throw new Error(`mihomo unsupported platform "${platform}-${arch}"`)
}
if (!META_ALPHA_MAP[`${platform}-${arch}`]) {
throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`)
throw new Error(`mihomo alpha unsupported platform "${platform}-${arch}"`)
}
function clashMetaAlpha() {
function mihomoAlpha() {
const name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function clashMetaAlpha() {
}
}
function clashMeta() {
function mihomo() {
const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz'
@@ -733,13 +733,12 @@ const tasks = [
{
name: 'verge-mihomo-alpha',
func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())),
getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
retry: 5,
},
{
name: 'verge-mihomo',
func: () =>
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
retry: 5,
},
// After both sidecar tasks: it hashes what they downloaded.
+47
View File
@@ -0,0 +1,47 @@
# React diagnostics
`pnpm dev:profile` runs the usual development launcher with a root React
Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns
copies of the latest 1,000 standard Profiler callback records; `.clear()` drops
them. Records contain timing and commit metadata, never business props. Normal
development and production builds do not load the collector.
`pnpm dev:wdyr` instead initializes why-did-you-render before loading the app
and uses its development JSX runtime. It reports avoidable renders for
`ProxyItem`, `ProxyItemMini` and `ProxyTunCard` in the console. The modes are
mutually exclusive; `CVR_REACT_DIAGNOSTICS` accepts only `profiler` or `wdyr` on
the development server. Each mode uses its own Vite cache. Production builds
ignore this variable and include neither diagnostic entry nor runtime.
`pnpm audit:react` scans supported source files under `src`. Pass file paths to
limit the scan, for example `pnpm audit:react src/hooks/use-proxy-delay-state.ts`;
add `--json` for the original structured report. Diagnostics are advisory.
Execution errors and incomplete scans fail the command. Telemetry, remote scores
and supply-chain scanning are disabled; the command does not fix code, erase
historical diagnostics or run in CI.
Install the browser explicitly with `pnpm exec playwright install chromium`.
Then run `pnpm react:ab --base <ref> --head <ref>`. Both Git trees run against
one installed dependency set and the same fixed DNS, delay-hook and proxy-tab
fixtures. The command never installs a browser or changes either checkout.
It retains source hashes, dependency identity, browser/environment information,
behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`.
Each version and mode has separate Vite caches and output. Three pairs alternate
order in ordinary development, Strict Mode and production. A behavior failure
returns a nonzero exit status. DNS stale serialization and same-value renders
are observations so a known baseline can be compared with its fix. Append
`--assert dns` to require the head version to keep the latest serialized YAML in sync with
continuous field edits; this is the focused DNS regression assertion.
`--assert delay` requires same-value delay updates to retain their reference and memoized consumer,
while each changed field (including clearing elapsed time) propagates.
`--assert tabs` requires zero tab renders during unrelated parent updates. Use
`--assert dns --assert delay --assert tabs` for a cumulative candidate.
The fixtures execute the actual selected components and hook. IPC, data,
editor and UI rendering boundaries are replaced, and outgoing network requests
are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is
observed at the Material Paper boundary. These are component results, not native
Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains.
Production React does not emit Profiler callbacks. Timing is diagnostic and has
no hard acceptance threshold; measure native release CPU/RSS separately and
calibrate A/A before claiming a performance gain. Keep raw reports out of Git.
+596
View File
@@ -0,0 +1,596 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
symlink,
writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import react from '@vitejs/plugin-react'
import { chromium } from 'playwright'
import { build, createServer, preview } from 'vite'
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
assert: { type: 'string', multiple: true },
},
})
if (!values.base || !values.head)
throw new Error('Usage: pnpm react:ab --base <ref> --head <ref>')
const assertions = new Set(values.assert ?? [])
for (const name of assertions) {
if (!['dns', 'delay', 'tabs'].includes(name))
throw new Error(`Unknown regression assertion: ${name}`)
}
const harness = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(harness, '../..')
const output = await mkdtemp('/tmp/cvr-react-ab-')
const dependencies = path.join(root, 'node_modules')
const frozenHarness = path.join(output, 'harness')
await cp(harness, frozenHarness, {
recursive: true,
filter: (file) => !path.basename(file).startsWith('tsconfig'),
})
const files = [
'src/components/setting/mods/dns-viewer.tsx',
'src/hooks/use-proxy-delay-state.ts',
'src/components/home/proxy-tun-card.tsx',
]
const hash = (content) => createHash('sha256').update(content).digest('hex')
function git(...args) {
const result = spawnSync('git', args, {
cwd: root,
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(result.stderr.toString())
return result.stdout
}
const manifest = {
assertions: [...assertions],
environment: {
platform: process.platform,
architecture: process.arch,
node: process.version,
os: os.release(),
lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))),
},
harness: Object.fromEntries(
await Promise.all(
(await readdir(frozenHarness)).map(async (file) => [
file,
hash(await readFile(path.join(frozenHarness, file))),
]),
),
),
installedLock: hash(
await readFile(path.join(dependencies, '.pnpm/lock.yaml')),
),
sources: {},
runs: [],
}
let browser
try {
// Playwright intentionally fails here if its explicitly installed browser is missing.
browser = await chromium.launch({ headless: true })
manifest.environment.browser = browser.version()
for (const [variant, ref] of Object.entries({
base: values.base,
head: values.head,
})) {
const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim()
const source = path.join(output, variant)
await mkdir(source)
const archive = git('archive', tree, 'src')
const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive })
if (extracted.status !== 0) throw new Error(extracted.stderr.toString())
await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true })
await symlink(dependencies, path.join(source, 'node_modules'), 'dir')
manifest.sources[variant] = {
ref,
tree,
files: Object.fromEntries(
await Promise.all(
files.map(async (file) => [
file,
hash(await readFile(path.join(source, file))),
]),
),
),
}
}
for (const mode of ['development', 'strict', 'production']) {
const servers = {}
try {
for (const variant of ['base', 'head']) {
const source = path.join(output, variant)
const fixture = path.join(source, 'fixture')
const boundary = path.join(fixture, 'boundaries.tsx')
const aliases = [
'@/hooks/use-clash',
'@/hooks/use-verge',
'@/hooks/use-profiles',
'@/hooks/use-system-state',
'@/hooks/use-system-proxy-state',
'@/services/states',
'@/services/cmds',
'@/services/mutate',
'@/services/notice-service',
'@/components/base',
'@/components/shared/proxy-control-switches',
'react-i18next',
].map((find) => ({ find, replacement: boundary }))
const config = {
configFile: false,
root: fixture,
cacheDir: path.join(output, 'cache', variant, mode),
plugins: [react()],
resolve: {
alias: [
...aliases,
{
find: '@/services/delay',
replacement: path.join(fixture, 'delay.ts'),
},
{
find: /^@mui\/material$/,
replacement: path.join(fixture, 'material.tsx'),
},
{
find: 'fixture-material',
replacement: path.join(dependencies, '@mui/material/index.mjs'),
},
{ find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') },
{
find: 'fixture-yaml',
replacement: path.join(
dependencies,
'js-yaml/dist/js-yaml.mjs',
),
},
{ find: '@', replacement: path.join(source, 'src') },
{
find: /^react(?=\/|$)/,
replacement: path.join(dependencies, 'react'),
},
{
find: /^react-dom(?=\/|$)/,
replacement: path.join(dependencies, 'react-dom'),
},
],
dedupe: ['react', 'react-dom'],
},
define: { OS_PLATFORM: JSON.stringify(process.platform) },
server: {
host: '127.0.0.1',
port: 0,
hmr: false,
fs: {
allow: [
output,
await realpath(output),
await realpath(dependencies),
],
},
},
build: {
outDir: path.join(output, 'dist', variant, mode),
emptyOutDir: true,
},
preview: { host: '127.0.0.1', port: 0 },
}
if (mode === 'production') {
await build(config)
servers[variant] = await preview(config)
} else {
servers[variant] = await createServer(config)
await servers[variant].listen()
}
}
for (const [pair, order] of [
['base', 'head'],
['head', 'base'],
['base', 'head'],
].entries()) {
for (const variant of order) {
const label = `${mode}-${pair + 1}-${variant}`
const context = await browser.newContext()
const errors = []
const address = servers[variant].httpServer.address()
const origin = `http://127.0.0.1:${address.port}`
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin === origin)
return route.continue()
errors.push(`Unexpected network: ${route.request().url()}`)
return route.abort()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(String(error)))
const result = { label, variant, mode, pair: pair + 1, errors }
try {
await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`)
await page.waitForSelector('#delay', { timeout: 60000 })
await page.waitForFunction(
() => window.__REACT_AB__.read().listeners.length === 1,
)
result.tabs = await tabs(page)
if (variant === 'head' && assertions.has('tabs')) {
assert.equal(
result.tabs.unrelated,
0,
'Unrelated parent updates must not rerender the memoized tabs',
)
}
result.dns = await dns(page)
if (variant === 'head' && assertions.has('dns')) {
for (const edit of result.dns.edits) {
assert.equal(
edit.serialized.at(-1),
edit.value,
'Latest DNS serialization must match the edited field',
)
}
}
result.delay = await delay(page)
if (variant === 'head' && assertions.has('delay')) {
for (const [index, observation] of result.delay.entries()) {
assert.equal(
observation.sameReference,
index === 1,
'Preserve the reference only when all three delay fields match',
)
}
assert.equal(
result.delay[1].childRenders,
result.delay[0].childRenders,
'Equal delay state must not rerender the memoized consumer',
)
}
result.profile = await page.evaluate(() => {
const before = window.__CVR_REACT_PROFILER__.read()
window.__CVR_REACT_PROFILER__.clear()
return { before, after: window.__CVR_REACT_PROFILER__.read() }
})
assert.equal(result.profile.after.length, 0)
result.collector = await page.evaluate(() =>
window.__REACT_AB__.checkCollector(),
)
assert.deepEqual(result.collector, {
length: 1000,
first: '5',
last: '1004',
copied: true,
cleared: true,
})
if (mode !== 'production')
assert(
result.profile.before.length > 0 &&
result.profile.before.length <= 1000,
)
assert.deepEqual(errors, [])
assert.deepEqual(
await page.evaluate(() =>
window.__REACT_AB__.fixture.notices.filter(
(notice) => notice.error,
),
),
[],
)
result.ok = true
} catch (error) {
result.snapshot = await page
.evaluate(() => window.__REACT_AB__?.read())
.catch(() => null)
result.ok = false
result.error = String(error)
throw error
} finally {
manifest.runs.push(result)
await writeFile(
path.join(output, `${label}.json`),
JSON.stringify(result, null, 2),
)
await context.close()
console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`)
}
}
}
} finally {
for (const server of Object.values(servers)) {
if (server.close) await server.close()
else
await new Promise((resolve, reject) =>
server.httpServer.close((error) =>
error ? reject(error) : resolve(),
),
)
}
}
}
} finally {
await browser?.close()
await writeFile(
path.join(output, 'report.json'),
JSON.stringify(manifest, null, 2),
)
console.log(`Evidence: ${output}`)
}
async function settle(page) {
await page.waitForTimeout(100)
}
async function tabs(page) {
const buttons = page.locator('#tabs .MuiPaper-root')
const readTabs = () =>
page.evaluate(() =>
Object.fromEntries(
window.__REACT_AB__.fixture.tabRenders.map((tab) => [
tab.label.split('.').at(-1),
tab,
]),
),
)
assert.equal(await buttons.count(), 2)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
const before = await page.evaluate(
() => window.__REACT_AB__.fixture.tabRenders.length,
)
for (let index = 0; index < 5; index++) {
await page.evaluate(() => window.__REACT_AB__.update())
await settle(page)
}
const unrelated =
(await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) -
before
await buttons.nth(1).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, false)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'tun',
)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: true }))
await settle(page)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal((await readTabs()).systemProxy.active, false)
await buttons.nth(0).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'system',
)
await page.evaluate(() => {
window.__REACT_AB__.fixture.indicator = true
window.__REACT_AB__.fixture.verge.enable_tun_mode = true
window.__REACT_AB__.update()
})
await settle(page)
assert(Object.values(await readTabs()).every((tab) => tab.indicator))
await page.evaluate(() => {
window.__REACT_AB__.fixture.language = 'zh'
window.__REACT_AB__.update()
})
await settle(page)
const updated = Object.values(await readTabs())
assert(updated.every((tab) => tab.label.startsWith('zh:')))
assert(
(await page.locator('#tabs').textContent()).includes(
'zh:home.components.proxyTun.status.systemProxyEnabled',
),
)
const beforeColor = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
await page.evaluate(() => {
window.__REACT_AB__.fixture.theme = 'dark'
window.__REACT_AB__.update()
})
await settle(page)
const color = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
assert.notEqual(color, beforeColor)
return { unrelated, updated, beforeColor, color }
}
async function dns(page) {
await page.evaluate(() => window.__REACT_AB__.open())
const listen = page.locator('#dns input[placeholder=":53"]')
await page.waitForFunction(
() =>
document.querySelector('#dns input[placeholder=":53"]')?.value ===
':5353',
)
const edits = []
for (const value of [':54', ':55', ':56']) {
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(value)
await settle(page)
assert.equal(await listen.inputValue(), value)
edits.push({
value,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map(
(config) => config.dns.listen,
),
),
})
}
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(':')
await listen.pressSequentially('567', { delay: 0 })
await settle(page)
assert.equal(await listen.inputValue(), ':567')
edits.push({
value: ':567',
rapid: true,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen),
),
})
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 1,
)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen),
':567',
)
await page.evaluate(() => window.__REACT_AB__.open())
await page.waitForSelector('#dns')
await page
.getByRole('button', { name: /shared.actions.resetToDefault$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':53')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
const editor = page.locator('#fixture-monaco')
assert.match(await editor.inputValue(), /listen: ["']?:53/)
await editor.fill(
'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n',
)
await page
.getByRole('button', { name: /shared.editorModes.visualization$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':8053')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 2,
)
const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved)
assert.equal(saved[1].dns.listen, ':8053')
assert.equal(saved[1].hosts['example.test'], '127.0.0.1')
return { edits, saved }
}
async function delay(page) {
const updates = [
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 101 },
{ delay: 11, updatedAt: 101 },
{ delay: -1, updatedAt: 102 },
{ delay: 0, updatedAt: 103 },
{ delay: 1000000, elapsed: 20, updatedAt: 104 },
]
const observations = []
for (const update of updates) {
await page.evaluate((value) => {
window.__REACT_AB__.remember()
window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value)
}, update)
await settle(page)
const result = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(result.delay, update)
observations.push({
update,
sameReference: result.sameReference,
childRenders: result.childRenders,
})
}
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }),
)
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
0,
)
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({
delay: 1000000,
elapsed: 20,
updatedAt: 201,
}),
)
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().delay),
{ delay: 1000000, elapsed: 20, updatedAt: 201 },
)
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
let state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 1:Node 2'])
assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 })
await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' }))
await settle(page)
state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 2:Node 2'])
for (const name of ['DIRECT', 'Unknown']) {
await page.evaluate((name) => window.__REACT_AB__.update({ name }), name)
await settle(page)
const preset = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(preset.listeners, [])
assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 })
}
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
['Group 2:Node 2'],
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
[],
)
return observations
}
+68
View File
@@ -0,0 +1,68 @@
import { Switch as MuiSwitch } from '@mui/material'
import { fixture } from './state'
const translations = {
en: (key: string) => `en:${key}`,
zh: (key: string) => `zh:${key}`,
}
export const useTranslation = () => ({
t: translations[fixture.language as 'en' | 'zh'],
})
export const useVerge = () => ({ verge: fixture.verge })
export const useProfiles = () => ({ current: { uid: 'fixture' } })
export const useClash = () => ({
mutateClash: () => {
throw new Error('Unexpected mutateClash')
},
})
export const useSystemState = () => ({ isTunModeAvailable: fixture.available })
export const useSystemProxyState = () => ({ indicator: fixture.indicator })
export const useThemeMode = () => fixture.theme
export const getDnsConfigContent = async () => {
fixture.commands.push('getDnsConfigContent')
return fixture.initialDns
}
export const saveDnsConfig = async (config: any) => {
fixture.commands.push('saveDnsConfig')
fixture.saved.push(structuredClone(config))
}
export const validateDnsConfig = async () => {
fixture.commands.push('validateDnsConfig')
return { status: 'valid' }
}
export const applyDnsConfig = () => {
throw new Error('Unexpected applyDnsConfig')
}
export const mutate = (fn: () => unknown) => fn()
export const showNotice = {
error: (...args: any[]) => fixture.notices.push({ error: args }),
success: (...args: any[]) => fixture.notices.push({ success: args }),
}
export const Switch = MuiSwitch
export function BaseDialog({ open, title, children, onClose, onOk }: any) {
return open ? (
<section id="dns">
{title}
<button id="close-dns" onClick={onClose}>
close
</button>
<button id="save-dns" onClick={onOk}>
save
</button>
{children}
</section>
) : null
}
export function MonacoEditor({ value, onChange }: any) {
return (
<textarea
id="fixture-monaco"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export default function ProxyControlSwitches({ label }: any) {
return <output id="fixture-controls">{label}</output>
}
+36
View File
@@ -0,0 +1,36 @@
type Update = { delay: number; elapsed?: number; updatedAt: number }
const listeners = new Map<string, (update: Update) => void>()
const cache = new Map<string, Update>()
let finish: ((update: Update) => void) | undefined
const key = (name: string, group: string) => `${group}:${name}`
export const delayFixture = {
listeners,
emit(name: string, group: string, update: Update) {
cache.set(key(name, group), update)
listeners.get(key(name, group))?.(update)
},
finish(update: Update) {
if (!finish) throw new Error('No pending delay request')
finish(update)
finish = undefined
},
}
export default {
setListener(name: string, group: string, listener: (update: Update) => void) {
listeners.set(key(name, group), listener)
},
removeListener(name: string, group: string) {
listeners.delete(key(name, group))
},
getDelayUpdate(name: string, group: string) {
return cache.get(key(name, group))
},
getDelayFix() {
return -1
},
checkDelay() {
return new Promise<Update>((resolve) => {
finish = resolve
})
},
}
+77
View File
@@ -0,0 +1,77 @@
import { spawnSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import { globSync } from 'glob'
const { values, positionals } = parseArgs({
options: { json: { type: 'boolean' } },
allowPositionals: true,
})
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const files = positionals.length
? positionals
: globSync('src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,html}', {
cwd: root,
nodir: true,
}).sort()
if (files.length === 0) throw new Error('No source files found')
for (const file of files) {
if (
!/\.(?:[jt]sx?|[cm][jt]s|html)$/.test(file) ||
!statSync(path.resolve(root, file)).isFile()
)
throw new Error(`Expected a source file: ${file}`)
}
const executable = fileURLToPath(
new URL('../bin/react-doctor.js', import.meta.resolve('react-doctor')),
)
const result = spawnSync(
process.execPath,
[
executable,
...files,
'--json',
'--no-telemetry',
'--no-supply-chain',
'--blocking',
'none',
'--yes',
],
{
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
},
)
if (result.error) throw result.error
if (result.signal)
throw new Error(`React Doctor terminated by ${result.signal}`)
const report = JSON.parse(result.stdout)
if (values.json) {
process.stdout.write(result.stdout)
} else {
for (const diagnostic of report.diagnostics) {
console.log(
`${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.plugin}/${diagnostic.rule}\n ${diagnostic.message}`,
)
}
console.log(
`${report.summary.totalDiagnosticCount} advisory diagnostics across ${report.projects.reduce((count, project) => count + project.analyzedFileCount, 0)} analyzed files`,
)
}
// In 0.9.21, --blocking none also masks linter execution failures.
const incomplete = report.projects.filter((project) => !project.complete)
if (report.error) console.error(report.error.message)
for (const project of incomplete) {
console.error(
'Incomplete React Doctor scan:',
project.directory,
project.skippedCheckReasons,
)
}
process.exitCode =
result.status || (!report.ok || incomplete.length > 0 ? 1 : 0)
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>React component A/B</title></head>
<body><div id="root"></div><script type="module" src="./scene.tsx"></script></body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import { Paper as MuiPaper } from 'fixture-material'
export * from 'fixture-material'
import { fixture } from './state'
export function Paper(props: any) {
const { onClick, elevation, children, sx } = props
if (onClick && sx?.maxWidth === 160) {
fixture.tabRenders.push({
active: elevation === 2,
indicator: Boolean(children[2]),
label: children[1].props.children,
})
}
return <MuiPaper {...props} />
}
+19
View File
@@ -0,0 +1,19 @@
import type { ProfilerOnRenderCallback } from 'react'
export type ProfileRecord = Parameters<ProfilerOnRenderCallback>
const records: ProfileRecord[] = []
export const recordRender: ProfilerOnRenderCallback = (...record) => {
if (records.length === 1000) records.shift()
records.push(record)
}
export const profiler = {
read: () => records.map((record) => [...record]),
clear: () => {
records.length = 0
},
}
Object.assign(window, { __CVR_REACT_PROFILER__: profiler })
+124
View File
@@ -0,0 +1,124 @@
import { ThemeProvider, createTheme } from '@mui/material'
import {
Profiler,
StrictMode,
memo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot } from 'react-dom/client'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { DnsViewer } from '@/components/setting/mods/dns-viewer'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import { delayFixture } from './delay'
import { profiler, recordRender } from './profiler'
import { fixture } from './state'
const member = (name: string): any =>
name === 'Unknown'
? {
kind: 'unresolved',
ref: { kind: 'unresolved', name, reason: 'missing' },
}
: {
kind: 'node',
ref: { kind: 'node', recordId: name, name },
node: { name, history: [], source: { kind: 'core', proxyName: name } },
}
let update: (patch?: any) => void
let open: () => void
let delay: ReturnType<typeof useProxyDelayState>
let previous: unknown
let childRenders = 0
const DelayValue = memo(({ state }: any) => {
childRenders++
return <output id="delay">{JSON.stringify(state)}</output>
})
function DelayProbe({ name, group }: any) {
const resolved = useMemo(() => member(name), [name])
const current = useProxyDelayState(resolved, group)
useEffect(() => {
delay = current
})
return <DelayValue state={current.delayState} />
}
function Scene() {
const [state, setState] = useState({
revision: 0,
name: 'Node 1',
group: 'Group 1',
mounted: true,
})
const ref = useRef<any>(null)
useEffect(() => {
update = (patch = {}) =>
setState((old) => ({ ...old, ...patch, revision: old.revision + 1 }))
open = () => ref.current.open()
}, [])
const mode = fixture.theme
const theme = useMemo(() => createTheme({ palette: { mode } }), [mode])
return (
<ThemeProvider theme={theme}>
<main data-revision={state.revision}>
<section id="tabs" key={state.mounted ? 'mounted' : 'reset'}>
<ProxyTunCard />
</section>
<DnsViewer ref={ref} />
{state.mounted && <DelayProbe name={state.name} group={state.group} />}
</main>
</ThemeProvider>
)
}
const strict = new URLSearchParams(location.search).has('strict')
const app = (
<Profiler id="fixtures" onRender={recordRender}>
<Scene />
</Profiler>
)
createRoot(document.getElementById('root')!).render(
strict ? <StrictMode>{app}</StrictMode> : app,
)
Object.assign(window, {
__REACT_AB__: {
update: (patch?: any) => update(patch),
open: () => open(),
fixture,
delay: delayFixture,
request: () => {
void delay.onDelay()
},
remember: () => {
previous = delay.delayState
},
checkCollector: () => {
profiler.clear()
for (let index = 0; index < 1005; index++)
recordRender(String(index), 'update', 1, 2, 3, 4)
const records = profiler.read()
records[0][0] = 'changed copy'
const preserved = profiler.read()
const result = {
length: preserved.length,
first: preserved[0][0],
last: preserved.at(-1)![0],
copied: preserved[0][0] === '5',
cleared: false,
}
profiler.clear()
result.cleared = profiler.read().length === 0
return result
},
read: () => ({
fixture,
profile: profiler.read(),
childRenders,
sameReference: previous === delay.delayState,
delay: delay.delayState,
listeners: [...delayFixture.listeners.keys()],
}),
},
})
+13
View File
@@ -0,0 +1,13 @@
export const fixture = {
language: 'en',
theme: 'light' as 'light' | 'dark',
verge: { enable_tun_mode: false },
indicator: false,
available: true,
initialDns: 'dns:\n enable: true\n listen: ":5353"\n',
saved: [] as any[],
serialized: [] as any[],
tabRenders: [] as any[],
notices: [] as any[],
commands: [] as string[],
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"],
"@root/*": ["../../*"],
"fixture-material": ["../../node_modules/@mui/material"],
"fixture-yaml": ["../../node_modules/js-yaml"]
}
},
"include": ["./*.ts", "./*.tsx", "../../src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"types": ["node"]
},
"include": ["./*.mjs", "./*.mts"]
}
+61
View File
@@ -0,0 +1,61 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
export function reactDiagnostics(mode: string | undefined): Plugin[] {
if (!mode) return []
return [
{
name: 'react-diagnostics',
enforce: 'pre',
apply: 'serve',
config() {
if (mode !== 'profiler' && mode !== 'wdyr')
throw new Error(`Unknown CVR_REACT_DIAGNOSTICS mode: ${mode}`)
return {
cacheDir: path.resolve('node_modules/.vite', mode),
...(mode === 'wdyr'
? {
oxc: {
jsx: {
importSource: '@welldone-software/why-did-you-render',
},
},
optimizeDeps: {
include: [
'@welldone-software/why-did-you-render',
'@welldone-software/why-did-you-render/jsx-dev-runtime',
],
},
}
: {}),
}
},
transformIndexHtml(html) {
if (mode !== 'wdyr') return html
const entry = 'src="./main.tsx"'
if (!html.includes(entry))
throw new Error('WDYR entry no longer matches index.html')
return html.replace(
entry,
`src="/@fs/${normalizePath(path.resolve('scripts/react/wdyr.ts'))}"`,
)
},
transform(code, id) {
if (mode !== 'profiler') return
if (id !== normalizePath(path.resolve('src/main.tsx'))) return
const open = '<React.StrictMode>'
const close = '</React.StrictMode>'
if (!code.includes(open) || !code.includes(close)) {
throw new Error('React profiling entry no longer matches main.tsx')
}
return {
code: `import { Profiler } from 'react'\nimport { recordRender } from '../scripts/react/profiler'\n${code}`
.replace(open, `<Profiler id="app" onRender={recordRender}>${open}`)
.replace(close, `${close}</Profiler>`),
map: null,
}
},
},
]
}
+10
View File
@@ -0,0 +1,10 @@
import whyDidYouRender from '@welldone-software/why-did-you-render'
import React from 'react'
whyDidYouRender(React, {
include: [/^(ProxyItem|ProxyItemMini|ProxyTunCard)$/],
trackAllPureComponents: false,
trackHooks: true,
})
await import('../../src/main')
+9
View File
@@ -0,0 +1,9 @@
import * as yaml from 'fixture-yaml'
export * from 'fixture-yaml'
import { fixture } from './state'
export function dump(value: any, options: any) {
fixture.serialized.push(structuredClone(value))
return yaml.dump(value, options)
}
+26 -21
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.9"
version = "2.6.0"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -29,13 +29,12 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies]
tauri-build = { version = "2.7.1", features = [] }
tauri-build = { version = "2.7.3", features = [] }
[dependencies]
clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -73,48 +72,54 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard",
] }
network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.4.0"
tauri-plugin-opener = "2.7.0"
tauri-plugin-dialog = "2.8.1"
tauri-plugin-fs = "2.6.0"
tauri-plugin-shell = "2.5.0"
tauri-plugin-opener = "2.7.2"
tauri-plugin-dialog = "2.8.3"
tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0"
zip = "9.0.1"
zip = "9.1.3"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11"
sha2 = "0.11.1"
getrandom = "0.4.3"
futures = "0.3.34"
gethostname = "1.1.0"
scopeguard = "1.2.0"
tauri-plugin-notification = "2.5.1"
tauri-plugin-notification = "2.7.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.0"
tauri-plugin-http = "2.8.3"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client",
] }
arc-swap = "1.9.2"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.45", features = ["ring"] }
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.9"
dark-light = "3.0.0"
bytes = "1.12.1"
tokio-util = { version = "0.7.19", features = ["time"] }
tokio-util = { version = "0.7.20", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies]
libc = "0.2.190"
libc = "0.2.191"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true }
objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [
"NSString",
@@ -147,8 +152,8 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.0"
tauri-plugin-updater = "2.13.1"
tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.1"
[dev-dependencies]
criterion = { workspace = true }
+10 -5
View File
@@ -1,10 +1,9 @@
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.
// Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses or examples.
// Give non-app targets their own manifest so tests and examples 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(
@@ -25,8 +24,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
</assembly>
"#,
)?;
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display());
let manifest_display = manifest_path.display();
if std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() {
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{manifest_display}");
} else {
println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg-examples=/MANIFESTINPUT:{manifest_display}");
}
}
#[cfg(feature = "clippy")]
+14
View File
@@ -0,0 +1,14 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1);
let output = args
.next()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
if args.next().is_some() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
}
app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?;
Ok(())
}
+8 -3
View File
@@ -21,6 +21,7 @@ ManifestDPIAwareness PerMonitorV2
!include "FileAssociation.nsh"
!include "Win\COM.nsh"
!include "Win\Propkey.nsh"
!include "Win\RestartManager.nsh"
!include "WinVer.nsh"
!include "LogicLib.nsh"
!include "StrFunc.nsh"
@@ -332,6 +333,8 @@ Function PageLeaveReinstall
${EndIf}
reinst_uninstall:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
HideWindow
ClearErrors
@@ -368,7 +371,10 @@ Function PageLeaveReinstall
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
Abort
${EndIf}
Return
reinst_done:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
FunctionEnd
; 5. Choose install directory page
@@ -901,8 +907,7 @@ Section Install
nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
; Ensure startup folders exist
@@ -1101,7 +1106,7 @@ Section Uninstall
!insertmacro NSIS_HOOK_PREUNINSTALL
!endif
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses
!insertmacro RemoveVergeService
-8
View File
@@ -1,8 +0,0 @@
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
//! the payload is derived from the same serializers the app uses at runtime.
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
//! TypeScript artifact.
fn main() {
println!("{}", app_lib::frontend_wire_contract());
}
+28 -8
View File
@@ -1,7 +1,10 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent},
core::{
SilentUpdater,
updater::{DownloadEvent, InstallUpdateOutcome},
},
feat,
utils::dirs,
};
@@ -9,12 +12,14 @@ use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel};
#[tauri::command]
#[specta::specta]
pub async fn open_app_dir() -> CmdResult<()> {
let app_dir = dirs::app_home_dir().stringify_err()?;
open::that(app_dir).stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn open_core_dir() -> CmdResult<()> {
let core_dir = tauri::utils::platform::current_exe().stringify_err()?;
let core_dir = core_dir.parent().ok_or("failed to get core dir")?;
@@ -22,12 +27,14 @@ pub async fn open_core_dir() -> CmdResult<()> {
}
#[tauri::command]
#[specta::specta]
pub async fn open_logs_dir() -> CmdResult<()> {
let log_dir = dirs::app_logs_dir().stringify_err()?;
open::that(log_dir).stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn open_devtools(app_handle: AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if !window.is_devtools_open() {
@@ -39,22 +46,25 @@ pub fn open_devtools(app_handle: AppHandle) {
}
#[tauri::command]
#[specta::specta]
pub async fn exit_app() {
feat::quit().await;
}
#[tauri::command]
#[specta::specta]
pub async fn restart_app() -> CmdResult<()> {
feat::restart_app().await;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn install_update(
app_handle: AppHandle,
version: String,
version: std::string::String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
) -> CmdResult<InstallUpdateOutcome> {
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
@@ -64,22 +74,32 @@ pub async fn install_update(
}
#[tauri::command]
#[specta::specta]
pub fn cancel_update_download() {
SilentUpdater::global().cancel_download();
}
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
#[specta::specta]
pub fn get_app_dir() -> CmdResult<std::string::String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir)
}
#[tauri::command]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> {
feat::download_icon_cache(url, name).await
#[specta::specta]
pub async fn download_icon_cache(
url: std::string::String,
name: std::string::String,
) -> CmdResult<std::string::String> {
let url: String = url.into();
let name: String = name.into();
feat::download_icon_cache(url, name).await.map(Into::into)
}
#[tauri::command]
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> {
feat::copy_icon_file(path, icon_info).await
#[specta::specta]
pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
let path: String = path.into();
feat::copy_icon_file(path, icon_info).await.map(Into::into)
}
+16 -5
View File
@@ -4,33 +4,44 @@ use feat::LocalBackupFile;
use smartstring::alias::String;
#[tauri::command]
#[specta::specta]
pub async fn create_local_backup() -> CmdResult<()> {
feat::create_local_backup().await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
feat::list_local_backup().await.stringify_err()
}
#[tauri::command]
pub async fn delete_local_backup(filename: String) -> CmdResult<()> {
#[specta::specta]
pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_local_backup(filename).await.stringify_err()
}
#[tauri::command]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
#[specta::specta]
pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_local_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))
}
#[tauri::command]
pub async fn import_local_backup(source: String) -> CmdResult<String> {
feat::import_local_backup(source).await.stringify_err()
#[specta::specta]
pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
let source: String = source.into();
feat::import_local_backup(source).await.map(Into::into).stringify_err()
}
#[tauri::command]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> {
#[specta::specta]
pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
let destination: String = destination.into();
feat::export_local_backup(filename, destination).await.stringify_err()
}
+36 -25
View File
@@ -12,30 +12,33 @@ use crate::{
},
};
use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use tokio::fs;
#[tauri::command]
#[specta::specta]
pub async fn copy_clash_env() -> CmdResult {
feat::copy_clash_env().await;
Ok(())
feat::copy_clash_env().await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn get_clash_info() -> CmdResult<ClashInfo> {
Ok(Config::clash().await.data_arc().get_client_info())
}
#[tauri::command]
pub async fn patch_clash_config(payload: Mapping) -> CmdResult {
feat::patch_clash(&payload)
#[specta::specta]
pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
feat::patch_clash(&payload.0)
.await
.map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED"))
}
#[tauri::command]
pub async fn patch_clash_mode(payload: String) -> CmdResult {
#[specta::specta]
pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
let payload: String = payload.into();
feat::change_clash_mode(payload)
.await
.with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -43,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command]
pub async fn get_clash_mode() -> CmdResult<Option<String>> {
Ok(Config::clash().await.data_arc().get_mode().map(Into::into))
#[specta::specta]
pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
Ok(Config::clash().await.data_arc().get_mode())
}
#[tauri::command]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> {
#[specta::specta]
pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
let clash_core: String = clash_core.into();
logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await {
@@ -80,6 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFa
}
#[tauri::command]
#[specta::specta]
pub async fn restart_core() -> CmdResult {
logging_error!(Type::Core, profiles_save_file_safe().await);
let result = CoreManager::global()
@@ -94,6 +101,7 @@ pub async fn restart_core() -> CmdResult {
/// Replaces the managed core binary atomically instead of letting mihomo overwrite itself.
#[tauri::command]
#[specta::specta]
pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeReport> {
let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?;
if report.upgraded {
@@ -103,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
}
#[tauri::command]
pub async fn test_delay(url: String) -> CmdResult<u32> {
#[specta::specta]
pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
let url: String = url.into();
let result = match feat::test_delay(url).await {
Ok(delay) => delay,
Err(e) => {
@@ -115,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult<u32> {
}
#[tauri::command]
pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
#[specta::specta]
pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult {
use crate::utils::dirs;
use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?;
let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?;
fs::write(&dns_path, yaml_str).await.stringify_err()?;
logging!(info, Type::Config, "DNS config saved to {dns_path:?}");
@@ -129,22 +140,27 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub fn take_dns_override_notice() -> bool {
crate::config::dns::take_dns_override_notice()
}
#[tauri::command]
#[specta::specta]
pub async fn set_dns_override(
profile_uid: String,
profile_uid: std::string::String,
enabled: bool,
confirmation: Option<String>,
confirmation: Option<std::string::String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid.into();
let confirmation = confirmation.map(String::from);
feat::set_dns_override(profile_uid, enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
}
#[tauri::command]
#[specta::specta]
pub async fn apply_dns_config(apply: bool) -> CmdResult {
if apply {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
@@ -163,7 +179,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
);
})?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(
error,
Type::Config,
@@ -172,13 +188,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
);
})?;
let mut patch = serde_yaml_ng::Mapping::new();
patch.insert("dns".into(), patch_config.into());
Config::runtime().await.edit_draft(|d| {
d.patch_config(&patch);
});
CoreManager::global().update_config_checked().await.map_err(|error| {
logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}");
proxy_aware_error(&error)
@@ -196,26 +205,27 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(info, Type::Config, "Config regenerated successfully");
}
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(())
}
#[tauri::command]
pub async fn get_dns_config_content() -> CmdResult<Option<String>> {
#[specta::specta]
pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
use crate::utils::dirs;
use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
match fs::read_to_string(&dns_path).await {
Ok(content) => Ok(Some(content.into())),
Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(CommandFailure::plain(error)),
}
}
#[tauri::command]
#[specta::specta]
pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
let app_dir = dirs::app_home_dir().stringify_err()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
@@ -232,6 +242,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
}
#[tauri::command]
#[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs)
+1
View File
@@ -3,6 +3,7 @@ use crate::core::lightweight;
use super::CmdResult;
#[tauri::command]
#[specta::specta]
pub async fn entry_lightweight_mode() -> CmdResult {
lightweight::entry_lightweight_mode().await;
Ok(())
+2
View File
@@ -5,11 +5,13 @@ use crate::{
};
#[tauri::command]
#[specta::specta]
pub async fn probe_listener(request: ListenerProbe) -> CmdResult<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err()
}
#[tauri::command]
#[specta::specta]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings)
.await
@@ -4,6 +4,7 @@ use std::time::Duration;
use tauri::{command, ipc::Channel};
#[command]
#[specta::specta]
pub fn get_unlock_items() -> Vec<UnlockItem> {
clash_verge_media_unlock::default_unlock_items()
}
@@ -26,6 +27,7 @@ async fn create_client() -> Result<Client, String> {
}
#[command]
#[specta::specta]
pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<UnlockItem>, String> {
let client = create_client().await?;
Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| {
@@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
}
#[command]
#[specta::specta]
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
let client = create_client().await?;
clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
+1 -1
View File
@@ -4,7 +4,7 @@ use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>;
/// Structured command failure returned to the frontend.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
pub struct CommandFailure {
/// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")]
+34 -20
View File
@@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _;
use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
#[tauri::command]
pub async fn get_sys_proxy() -> CmdResult<Mapping> {
#[specta::specta]
pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
Sysopt::global().wait_idle().await;
// With no network service there is no proxy configured anywhere, which reads as disabled.
let sys_proxy = match Sysproxy::get_system_proxy() {
@@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult<Mapping> {
other => other.stringify_err()?,
};
let Sysproxy {
ref host,
ref bypass,
ref port,
ref enable,
host,
bypass,
port,
enable,
} = sys_proxy;
let mut map = Mapping::new();
map.insert("enable".into(), (*enable).into());
map.insert("server".into(), format!("{}:{}", host, port).into());
map.insert("bypass".into(), bypass.as_str().into());
Ok(map)
Ok(SystemProxy {
enable,
server: format!("{host}:{port}"),
bypass,
})
}
#[tauri::command]
pub async fn get_auto_proxy() -> CmdResult<Mapping> {
#[specta::specta]
pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
Sysopt::global().wait_idle().await;
let auto_proxy = match Autoproxy::get_auto_proxy() {
Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(),
other => other.stringify_err()?,
};
let Autoproxy { ref enable, ref url } = auto_proxy;
let Autoproxy { enable, url } = auto_proxy;
let mut map = Mapping::new();
map.insert("enable".into(), (*enable).into());
map.insert("url".into(), url.as_str().into());
Ok(map)
Ok(AutoProxy { enable, url })
}
#[tauri::command]
#[specta::specta]
pub fn get_embedded_server_port() -> CmdResult<u16> {
crate::utils::server::embedded_server_port().stringify_err()
}
#[tauri::command]
#[specta::specta]
pub fn get_system_hostname() -> String {
match gethostname().into_string() {
Ok(name) => name,
@@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String {
}
#[tauri::command]
#[specta::specta]
pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
}
#[tauri::command]
pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
#[specta::specta]
pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfaceView>> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
let names = get_network_interfaces();
@@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<NetworkInterface>> {
for interface in interfaces {
if names.contains(&interface.name) {
result.push(interface);
result.push(interface.into());
}
}
Ok(result)
}
#[derive(serde::Serialize, specta::Type)]
pub struct SystemProxy {
pub enable: bool,
pub server: String,
pub bypass: String,
}
#[derive(serde::Serialize, specta::Type)]
pub struct AutoProxy {
pub enable: bool,
pub url: String,
}
+117 -26
View File
@@ -33,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String {
}
#[tauri::command]
#[specta::specta]
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
let draft = Config::profiles().await;
let data = draft.data_arc();
@@ -40,6 +41,7 @@ pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
}
#[tauri::command]
#[specta::specta]
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
match feat::enhance_profiles().await {
Ok(outcome) if outcome.is_valid() => {
@@ -65,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta]
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it,
@@ -94,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option<PrfOption>)
}
#[tauri::command]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
#[specta::specta]
pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
let active_id: String = active_id.into();
let over_id: String = over_id.into();
match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -114,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
}
#[tauri::command]
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
#[specta::specta]
pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
let item: PrfItem = item.into();
let file_data = file_data.map(String::from);
match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => {
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 {
handle::Handle::notify_profile_changed(uid);
}
Ok(())
}
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
@@ -131,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResu
}
#[tauri::command]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
#[specta::specta]
pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
let index: String = index.into();
match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()),
Err(e) => {
@@ -142,7 +150,9 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
}
#[tauri::command]
pub async fn delete_profile(index: String) -> CmdResult {
#[specta::specta]
pub async fn delete_profile(index: std::string::String) -> CmdResult {
let index: String = index.into();
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await;
@@ -181,10 +191,6 @@ pub async fn delete_profile(index: String) -> CmdResult {
drop(config_update_guard);
drop(profile_write_guard);
if should_update {
logging_error!(Type::Config, Config::sync_dns_override().await);
}
if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
}
@@ -254,7 +260,6 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationO
handle::Handle::notify_profile_changed(current);
}
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(ValidationOutcome::Valid)
}
@@ -272,7 +277,9 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
discard_and_restore(current_profile).await?;
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
if !matches!(outcome, ValidationOutcome::Busy) {
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
}
Ok(outcome)
}
@@ -301,9 +308,6 @@ async fn perform_config_update(
current_value: Option<&String>,
current_profile: Option<&String>,
) -> CmdResult<ValidationOutcome> {
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result {
@@ -313,9 +317,82 @@ async fn perform_config_update(
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileSelection {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>,
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfileCreate {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub option: Option<PrfOption>,
}
impl From<ProfileCreate> for PrfItem {
fn from(item: ProfileCreate) -> Self {
Self {
itype: item.itype,
name: item.name,
desc: item.desc,
url: item.url,
option: item.option,
..Self::default()
}
}
}
#[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)]
pub struct ProfilePatch {
#[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>,
pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>,
pub option: Option<PrfOption>,
}
impl From<ProfilePatch> for PrfItem {
fn from(patch: ProfilePatch) -> Self {
Self {
itype: patch.itype,
name: patch.name,
desc: patch.desc,
file: patch.file,
url: patch.url,
selected: patch.selected,
extra: patch.extra,
updated: patch.updated,
option: patch.option,
..Self::default()
}
}
}
#[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))]
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
#[specta::specta]
pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<ValidationOutcome> {
if CURRENT_SWITCHING_PROFILE
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
@@ -323,13 +400,22 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(ValidationOutcome::Busy);
}
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles {
current: profiles.current,
items: None,
};
let target_profile = profiles.current.as_ref();
let previous_profile = Config::profiles().await.data_arc().current.clone();
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
Config::profiles().await.edit_draft(|d| {
d.patch_config(&profiles);
});
perform_config_update(target_profile, previous_profile.as_ref())
.await
@@ -337,15 +423,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationO
}
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
let profiles = IProfiles {
let profiles = ProfileSelection {
current: Some(profile_index),
items: None,
};
patch_profiles_config(profiles).await
}
#[tauri::command]
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
#[specta::specta]
pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into();
let index: String = index.into();
let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref()
@@ -385,7 +473,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
}
#[tauri::command]
pub async fn view_profile(index: String) -> CmdResult {
#[specta::specta]
pub async fn view_profile(index: std::string::String) -> CmdResult {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
let file = profiles_ref
@@ -409,7 +498,8 @@ pub async fn view_profile(index: String) -> CmdResult {
}
#[tauri::command]
pub async fn read_profile_file(index: String) -> CmdResult<String> {
#[specta::specta]
pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
let item = {
let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc();
@@ -429,7 +519,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
.join(file.as_str());
match tokio::fs::try_exists(&path).await {
Ok(true) => {}
Ok(false) => return Ok(String::new()),
Ok(false) => return Ok(std::string::String::new()),
Err(err) => {
return Err(coded_error(
"PROFILE_READ_FAILED",
@@ -440,11 +530,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
}
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data)
Ok(data.into())
}
#[tauri::command]
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
#[specta::specta]
pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time)
+6 -14
View File
@@ -18,6 +18,7 @@ use std::{
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
#[specta::specta]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
.await
@@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
}
#[tauri::command]
#[specta::specta]
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
.await
@@ -104,37 +106,27 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
}
#[tauri::command]
#[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),);
let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order,
group_scopes,
proxies,
providers,
providers: providers.ok(),
}))
}
#[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
+41 -36
View File
@@ -1,44 +1,57 @@
use super::CmdResult;
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;
use smartstring::alias::String;
use std::collections::HashMap;
#[tauri::command]
pub async fn get_runtime_config() -> CmdResult<Option<Mapping>> {
Ok(Config::runtime().await.latest_arc().config.clone())
#[specta::specta]
pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>> {
Ok(Config::runtime()
.await
.latest_arc()
.config
.clone()
.map(crate::ipc::YamlMapping))
}
#[tauri::command]
pub async fn get_runtime_yaml() -> CmdResult<String> {
#[specta::specta]
pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
let config = runtime.config.as_ref();
config
.ok_or_else(|| anyhow!("failed to parse config to yaml file"))
.and_then(|config| {
yaml_emitter::to_mihomo_config_string(config)
.context("failed to convert config to yaml")
.map(|s| s.into())
})
.and_then(|config| yaml_emitter::to_mihomo_config_string(config).context("failed to convert config to yaml"))
.stringify_err()
}
#[tauri::command]
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
#[specta::specta]
pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
Ok(RuntimeLogs(Config::runtime().await.latest_arc().chain_logs.clone()))
}
#[derive(serde::Serialize, specta::Type)]
#[serde(transparent)]
pub struct RuntimeLogs(
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
HashMap<String, Vec<(String, String)>>,
);
#[tauri::command]
#[specta::specta]
pub fn take_discarded_keys_notice() -> Option<std::string::String> {
crate::enhance::take_discarded_keys_notice().map(Into::into)
}
#[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> {
crate::enhance::take_discarded_keys_notice()
}
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
#[specta::specta]
pub async fn get_runtime_proxy_chain_config(
proxy_chain_exit_node: std::string::String,
) -> CmdResult<std::string::String> {
let runtime = Config::runtime().await;
let runtime = runtime.latest_arc();
@@ -59,7 +72,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
false
}
}) {
proxies_chain.push(proxy.to_owned());
proxies_chain.push(proxy);
proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str());
}
@@ -68,18 +81,17 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
.find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name)
&& !proxies_chain.is_empty()
{
proxies_chain.push(entry_proxy.to_owned());
proxies_chain.push(entry_proxy);
}
proxies_chain.reverse();
let mut config: HashMap<String, Vec<serde_yaml_ng::Value>> = HashMap::new();
let mut config: HashMap<String, Vec<&serde_yaml_ng::Value>> = HashMap::new();
config.insert("proxies".into(), proxies_chain);
yaml_emitter::to_mihomo_config_string(&config)
.context("YAML generation failed")
.map(|s| s.into())
.stringify_err()
} else {
Err("failed to get proxies or proxy-groups".into())
@@ -87,20 +99,13 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm
}
#[tauri::command]
pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option<serde_yaml_ng::Value>) -> CmdResult<()> {
match CoreManager::global()
#[specta::specta]
pub async fn update_proxy_chain_config_in_runtime(
proxy_chain_config: Option<crate::ipc::YamlValue>,
) -> CmdResult<crate::core::validate::ValidationOutcome> {
let proxy_chain_config = proxy_chain_config.map(|value| value.0);
CoreManager::global()
.update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config))
.await
{
Ok(outcome) if outcome.is_valid() => {}
Ok(outcome) => logging!(
warn,
Type::Core,
"Failed to apply runtime proxy chain config: {}",
outcome
),
Err(err) => logging!(error, Type::Core, "Failed to apply runtime proxy chain config: {err:#}"),
}
Ok(())
.stringify_err()
}
+8 -3
View File
@@ -10,13 +10,19 @@ use crate::{
},
utils::dirs,
};
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use tokio::fs;
/// 保存profiles的配置
#[tauri::command]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> {
#[specta::specta]
pub async fn save_profile_file(
index: std::string::String,
file_data: Option<std::string::String>,
) -> CmdResult<ValidationOutcome> {
let index: String = index.into();
let file_data = file_data.map(String::from);
let file_data = match file_data {
Some(d) => d,
None => return Ok(ValidationOutcome::Valid),
@@ -162,7 +168,6 @@ async fn handle_saved_profile_file(
);
match CoreManager::global().update_config_forced().await {
Ok(outcome) if outcome.is_valid() => {
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash();
Ok(ValidationOutcome::Valid)
}
+13 -2
View File
@@ -8,11 +8,11 @@ use crate::{
},
};
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome {
Installed,
#[cfg(any(windows, test))]
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
Sidecar {
reason: String,
},
@@ -82,11 +82,13 @@ where
}
#[tauri::command]
#[specta::specta]
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
#[tauri::command]
#[specta::specta]
pub async fn uninstall_service() -> CmdResult {
CoreManager::global()
.uninstall_service_and_start_sidecar()
@@ -95,16 +97,19 @@ pub async fn uninstall_service() -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
#[specta::specta]
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
#[tauri::command]
#[specta::specta]
pub async fn continue_with_sidecar() -> CmdResult {
crate::core::CoreManager::global()
.continue_with_sidecar(None)
@@ -113,31 +118,37 @@ pub async fn continue_with_sidecar() -> CmdResult {
}
#[tauri::command]
#[specta::specta]
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
crate::core::notification::sidecar_failure_snapshot()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
#[specta::specta]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
#[specta::specta]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
+2
View File
@@ -5,11 +5,13 @@ use crate::core::{
/// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command]
#[specta::specta]
pub async fn get_runtime_state() -> Result<RunStateView, String> {
Ok(RUN_STATE.settled().await.to_view())
}
#[tauri::command]
#[specta::specta]
pub async fn get_pending_failures() -> Vec<PendingFailure> {
notification::pending_failures()
}
+1
View File
@@ -22,6 +22,7 @@ mod platform {
}
#[tauri::command]
#[specta::specta]
pub async fn invoke_uwp_tool() -> CmdResult {
platform::invoke_uwp_tool()
}
+2
View File
@@ -9,12 +9,14 @@ use clash_verge_draft::SharedDraft;
/// 获取Verge配置
#[tauri::command]
#[specta::specta]
pub async fn get_verge_config() -> CmdResult<SharedDraft<IVerge>> {
feat::fetch_verge_config().await.stringify_err()
}
/// 修改Verge配置
#[tauri::command]
#[specta::specta]
pub async fn patch_verge_config(payload: IVerge) -> CmdResult {
let operation = system_proxy_operation(&payload);
let result = match operation {
+22 -6
View File
@@ -4,11 +4,18 @@ use crate::{
config::{Config, IVerge},
core, feat,
};
use reqwest_dav::list_cmd::ListFile;
use smartstring::alias::String;
#[tauri::command]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> {
#[specta::specta]
pub async fn save_webdav_config(
url: std::string::String,
username: std::string::String,
password: std::string::String,
) -> CmdResult<()> {
let url: String = url.into();
let username: String = username.into();
let password: String = password.into();
let patch = IVerge {
webdav_url: Some(url),
webdav_username: Some(username),
@@ -25,22 +32,31 @@ pub async fn save_webdav_config(url: String, username: String, password: String)
}
#[tauri::command]
#[specta::specta]
pub async fn create_webdav_backup() -> CmdResult<()> {
feat::create_backup_and_upload_webdav().await.stringify_err()
}
#[tauri::command]
pub async fn list_webdav_backup() -> CmdResult<Vec<ListFile>> {
feat::list_wevdav_backup().await.stringify_err()
#[specta::specta]
pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
feat::list_wevdav_backup()
.await
.map(|files| files.into_iter().map(Into::into).collect())
.stringify_err()
}
#[tauri::command]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> {
#[specta::specta]
pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::delete_webdav_backup(filename).await.stringify_err()
}
#[tauri::command]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> {
#[specta::specta]
pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename.into();
feat::restore_webdav_backup(filename)
.await
.map_err(|error| super::proxy_aware_error(&error))

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