Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andTunglies 366893a368 docs(readme): clarify development service prerequisite
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-11-03 13:15:57 +00:00
Tunglies 1105eedeb6 fix(react): avoid redundant DNS and proxy updates (#8543) 2026-11-03 12:58:43 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 891757ede6 chore(deps): update dependency vite to v8.3.3 [security] (#8538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 12:36:14 +00: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
185 changed files with 3506 additions and 3522 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.22.1", "version": "26.13.0",
"pnpmVersion": "12.15.0" "pnpmVersion": "12.16.1"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1", "version": "1.99.1",
+13 -58
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -157,15 +157,12 @@ jobs:
toolchain: '1.99.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -185,17 +182,9 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
cache: 'pnpm' 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 - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
@@ -204,12 +193,6 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version - name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest run: pnpm release-version autobuild-latest
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows-macOS-Linux - name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v1 uses: tauri-apps/tauri-action@v1
env: env:
@@ -260,15 +243,12 @@ jobs:
toolchain: '1.99.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -282,17 +262,9 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
cache: 'pnpm' 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 - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
@@ -345,12 +317,6 @@ jobs:
gcc-arm-linux-gnueabihf \ gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri Build for Linux - name: Tauri Build for Linux
run: | run: |
export PKG_CONFIG_ALLOW_CROSS=1 export PKG_CONFIG_ALLOW_CROSS=1
@@ -404,15 +370,18 @@ jobs:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Add Rust Target - name: Install Rust Stable
run: rustup target add ${{ matrix.target }} uses: dtolnay/rust-toolchain@master
with:
toolchain: '1.99.1'
targets: ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -426,17 +395,9 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
cache: 'pnpm' 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 - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
@@ -452,12 +413,6 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json 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.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build for Windows - name: Tauri build for Windows
id: build id: build
uses: tauri-apps/tauri-action@v1 uses: tauri-apps/tauri-action@v1
@@ -524,7 +479,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.0
name: Install pnpm 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 - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -11
View File
@@ -77,11 +77,12 @@ jobs:
uses: dtolnay/rust-toolchain@1.99.1 uses: dtolnay/rust-toolchain@1.99.1
- name: Rust Cache - name: Rust Cache
if: github.event.inputs[matrix.input] == 'true'
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -103,18 +104,9 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
cache: 'pnpm' 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 - name: Pnpm install and check
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
run: | run: |
+14 -112
View File
@@ -2,11 +2,23 @@ name: Frontend Check
on: on:
pull_request: 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: workflow_dispatch:
permissions: permissions:
contents: read contents: read
pull-requests: read
env: env:
HUSKY: 0 HUSKY: 0
@@ -17,142 +29,32 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - 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 - name: Install pnpm
if: steps.check_frontend.outputs.frontend == 'true'
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: '24.22.1' node-version: '26.13.0'
cache: 'pnpm' 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 - run: pnpm install --frozen-lockfile
if: steps.check_frontend.outputs.frontend == 'true'
- name: Run Prettier check - name: Run Prettier check
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm format:check run: pnpm format:check
- name: Run ESLint - name: Run ESLint
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm lint run: pnpm lint
- name: Run TypeScript typecheck - name: Run TypeScript typecheck
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm typecheck run: pnpm typecheck
- name: Run tests - name: Run tests
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm test run: pnpm test
- name: Run Knip - name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services) - 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 run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: scripts/contract-inputs.json
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.22.1'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Check generated contract without changing the checkout
if: steps.check_inputs.outputs.contract == 'true'
run: pnpm gen:contract --check
+9 -23
View File
@@ -2,6 +2,14 @@ name: Clippy Lint
on: on:
pull_request: pull_request:
paths:
- "src-tauri/**"
- "crates/**"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".cargo/config.toml"
- ".github/workflows/lint-clippy.yml"
workflow_dispatch: workflow_dispatch:
env: env:
HUSKY: 0 HUSKY: 0
@@ -23,28 +31,6 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: 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 - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -79,7 +65,7 @@ jobs:
run: cargo clippy-all run: cargo clippy-all
- name: Run Windows security tests - 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 working-directory: ./src-tauri
run: cargo test --all-features --target ${{ matrix.target }} 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"
+141 -118
View File
@@ -1,6 +1,9 @@
name: Release Build name: Release Build
on: on:
pull_request:
types: [edited]
branches: [main]
push: push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags: tags:
@@ -11,75 +14,59 @@ env:
RUST_BACKTRACE: short RUST_BACKTRACE: short
HUSKY: 0 HUSKY: 0
concurrency: concurrency:
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' group: release-publication
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: false
jobs: jobs:
check_tag_version: prepare_release:
name: Check Release Tag and package.json Version Consistency 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 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: steps:
- name: Checkout repository - name: Checkout trusted release tooling
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
ref: refs/heads/dev
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7
- 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
with: with:
tag_name: ${{ github.ref_name }} node-version: '26.13.0'
target_commitish: ${{ github.sha }} - name: Validate source and publish the approved snapshot
name: 'Clash Verge Rev ${{ github.ref_name }}' id: source
body: 'Draft release, will be updated later.' env:
token: ${{ secrets.GITHUB_TOKEN }} RELEASE_COMMAND: ${{ github.event_name == 'push' && 'validate-tag' || 'publish' }}
draft: true run: node scripts-workflow/release.mjs "$RELEASE_COMMAND"
prerelease: ${{ contains(github.ref_name, '-rc') }}
- 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: 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 runs-on: ubuntu-latest
needs: needs:
[ [
@@ -91,6 +78,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Group changelog platforms - name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh run: bash ./scripts-workflow/group_platforms.sh
@@ -103,7 +92,7 @@ jobs:
- name: Set Env - name: Set Env
run: | run: |
echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV echo "BUILDTIME=$(TZ=Asia/Shanghai date)" >> $GITHUB_ENV
TAG_REF="${GITHUB_REF##*/}" TAG_REF="$RELEASE_TAG"
echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV echo "TAG_NAME=$TAG_REF" >> $GITHUB_ENV
VERSION=$(echo "$TAG_REF" | sed 's/^v//') VERSION=$(echo "$TAG_REF" | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -149,10 +138,16 @@ jobs:
Created at ${{ env.BUILDTIME }}. Created at ${{ env.BUILDTIME }}.
EOF 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 - name: Publish Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRERELEASE: ${{ contains(github.ref_name, '-rc') }} PRERELEASE: ${{ contains(env.RELEASE_TAG, '-rc') }}
run: | run: |
release_id="${{ needs.prepare_release.outputs.release_id }}" release_id="${{ needs.prepare_release.outputs.release_id }}"
@@ -181,6 +176,8 @@ jobs:
release: release:
name: Release Build name: Release Build
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -200,6 +197,8 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -207,15 +206,12 @@ jobs:
toolchain: '1.99.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -227,29 +223,23 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf 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.22.1'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
with: with:
run_install: false run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
pnpm run prebuild ${{ matrix.target }} pnpm run prebuild ${{ matrix.target }}
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build - name: Tauri build
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v1.0.0 uses: tauri-apps/tauri-action@v1.0.0
env: env:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
@@ -264,11 +254,14 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
releaseId: ${{ needs.prepare_release.outputs.release_id }} releaseId: ${{ needs.prepare_release.outputs.release_id }}
tagName: ${{ github.ref_name }} tagName: ${{ env.RELEASE_TAG }}
releaseBody: 'Draft release, will be updated later.' 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 tauriScript: pnpm
args: --target ${{ matrix.target }} args: --target ${{ matrix.target }}
includeUpdaterJson: true uploadUpdaterJson: ${{ needs.prepare_release.outputs.channel == 'stable' }}
- name: Attest Windows bundles - name: Attest Windows bundles
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'
@@ -293,6 +286,8 @@ jobs:
release-for-linux-arm: release-for-linux-arm:
name: Release Build for Linux ARM name: Release Build for Linux ARM
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -307,6 +302,8 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -314,30 +311,28 @@ jobs:
toolchain: '1.99.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
cache-workspace-crates: true cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
with: with:
run_install: false run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
@@ -387,12 +382,6 @@ jobs:
gcc-arm-linux-gnueabihf \ gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf g++-arm-linux-gnueabihf
- name: Add Rust Target
run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Build for Linux - name: Build for Linux
run: | run: |
export PKG_CONFIG_ALLOW_CROSS=1 export PKG_CONFIG_ALLOW_CROSS=1
@@ -420,7 +409,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release upload "${{ github.ref_name }}" \ gh release upload "$RELEASE_TAG" \
target/${{ matrix.target }}/release/bundle/deb/*.deb \ target/${{ matrix.target }}/release/bundle/deb/*.deb \
target/${{ matrix.target }}/release/bundle/rpm/*.rpm \ target/${{ matrix.target }}/release/bundle/rpm/*.rpm \
--clobber --clobber
@@ -428,6 +417,8 @@ jobs:
release-for-fixed-webview2: release-for-fixed-webview2:
name: Release Build for Fixed WebView2 name: Release Build for Fixed WebView2
needs: [prepare_release] needs: [prepare_release]
env:
RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -442,6 +433,8 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare_release.outputs.source_sha }}
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -449,30 +442,28 @@ jobs:
toolchain: '1.99.1' toolchain: '1.99.1'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: 'v1-rust' prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' shared-key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}-fixed-webview2'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
cache-workspace-crates: true cache-workspace-crates: true
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '24.22.1'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
with: with:
run_install: false run_install: false
- name: Install Node
uses: actions/setup-node@v7
with:
node-version: '26.13.0'
cache: 'pnpm'
- name: Pnpm install and check - name: Pnpm install and check
run: | run: |
pnpm i pnpm i
@@ -485,12 +476,6 @@ jobs:
Remove-Item .\src-tauri\tauri.windows.conf.json Remove-Item .\src-tauri\tauri.windows.conf.json
Rename-Item .\src-tauri\webview2.${{ matrix.arch }}.json 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.1 || rustup target add ${{ matrix.target }}
rustup target list --installed
echo "Rust target ${{ matrix.target }} installed."
- name: Tauri build - name: Tauri build
uses: tauri-apps/tauri-action@v1.0.0 uses: tauri-apps/tauri-action@v1.0.0
env: env:
@@ -532,22 +517,55 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
$files = Get-ChildItem "target\${{ matrix.target }}\release\bundle\nsis\*setup*" | Select-Object -ExpandProperty FullName $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 } 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: release-update:
if: ${{ !contains(github.ref_name, '-rc') }} if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Release Update name: Release Update
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag] needs: [update_tag]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -563,17 +581,19 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release-update-for-fixed-webview2: release-update-for-fixed-webview2:
if: ${{ !contains(github.ref_name, '-rc') }} if: ${{ needs.update_tag.outputs.channel == 'stable' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag] needs: [update_tag]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -589,7 +609,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
submit-to-winget: submit-to-winget:
if: ${{ !contains(github.ref_name, '-rc') }} if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Submit to Winget name: Submit to Winget
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [update_tag, release-update] needs: [update_tag, release-update]
@@ -597,6 +617,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
ref: ${{ needs.update_tag.outputs.source_sha }}
fetch-depth: 0 fetch-depth: 0
- name: Get Version - name: Get Version
run: | run: |
@@ -613,7 +634,7 @@ jobs:
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}
notify-telegram: notify-telegram:
if: ${{ !contains(github.ref_name, '-rc') }} if: ${{ needs.update_tag.outputs.channel == 'stable' }}
name: Notify Telegram name: Notify Telegram
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
@@ -626,6 +647,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.update_tag.outputs.source_sha }}
- name: Group changelog platforms - name: Group changelog platforms
run: bash ./scripts-workflow/group_platforms.sh run: bash ./scripts-workflow/group_platforms.sh
@@ -638,7 +661,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+6 -15
View File
@@ -6,6 +6,12 @@ name: Check Formatting
on: on:
pull_request: pull_request:
paths:
- "src-tauri/**"
- "**/*.rs"
- "rustfmt.toml"
- "rust-toolchain.toml"
- ".github/workflows/rustfmt.yml"
permissions: permissions:
contents: read contents: read
@@ -19,25 +25,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - 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 - name: install Rust stable and rustfmt
if: steps.check_rust.outputs.rust == 'true'
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt components: rustfmt
- name: run cargo fmt - name: run cargo fmt
if: steps.check_rust.outputs.rust == 'true'
run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check run: cargo fmt --manifest-path ./src-tauri/Cargo.toml --all -- --check
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.1' node-version: '26.13.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -1,3 +1,3 @@
nodejs 24.22.1 nodejs 26.13.0
pnpm 12.15.0 pnpm 12.16.1
rust 1.99.1 rust 1.99.1
+19 -6
View File
@@ -28,7 +28,7 @@ Before contributing, you need to set up your development environment. Follow the
### Prerequisites ### Prerequisites
1. **Install Rust and Node.js** 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 ### Windows Users
@@ -51,18 +51,21 @@ rustup set default-host x86_64-pc-windows-msvc
### Install Node.js Package Manager ### 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 ```bash
npm install --global corepack
corepack enable corepack enable
``` ```
Run pnpm from this repository so Corepack selects the version pinned by `packageManager` in [`package.json`](package.json).
### Install Project Dependencies ### Install Project Dependencies
Node.js dependencies: Node.js dependencies:
```bash ```bash
pnpm install pnpm install --frozen-lockfile
``` ```
Ubuntu-only system packages: Ubuntu-only system packages:
@@ -78,14 +81,24 @@ pnpm run prebuild
pnpm run prebuild --force # Re-download and overwrite Mihomo core and service binaries 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 the Development Server
Run these commands from the root of `clash-verge-rev`:
```bash ```bash
pnpm dev # Standard pnpm dev # Build the development service and start the desktop app
pnpm dev:diff # If an app instance already exists pnpm dev:diff # Alias of pnpm dev
pnpm dev:tauri # Run Tauri development mode 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 ### Build the Project
Standard build: Standard build:
Generated
+54 -82
View File
@@ -33,6 +33,7 @@ dependencies = [
"cipher", "cipher",
"cpubits", "cpubits",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
"zeroize",
] ]
[[package]] [[package]]
@@ -135,15 +136,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "arboard" name = "arboard"
version = "3.6.1" version = "3.6.1"
@@ -1089,7 +1081,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "clash-verge" name = "clash-verge"
version = "2.5.9" version = "2.6.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -1119,7 +1111,6 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"libc", "libc",
"log", "log",
"minisign-verify",
"nanoid", "nanoid",
"network-interface", "network-interface",
"objc2", "objc2",
@@ -1139,6 +1130,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml_ng", "serde_yaml_ng",
"sha2 0.11.1", "sha2 0.11.1",
"smartstring",
"socket2", "socket2",
"specta", "specta",
"specta-typescript", "specta-typescript",
@@ -1168,14 +1160,13 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"tracing", "tracing",
"tristr",
"warp", "warp",
"webpki-roots", "webpki-roots",
"windows 0.62.2", "windows 0.62.2",
"windows-service", "windows-service",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.56.0", "winreg 0.56.0",
"zip 9.0.4", "zip",
] ]
[[package]] [[package]]
@@ -1237,8 +1228,8 @@ dependencies = [
[[package]] [[package]]
name = "clash_verge_service_ipc" name = "clash_verge_service_ipc"
version = "2.7.6" version = "2.7.7"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6" source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.7#3ed29700b093190b5e7addcaa892e77f172af544"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"kode-bridge", "kode-bridge",
@@ -1342,7 +1333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1870,17 +1861,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -2034,7 +2014,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -2293,7 +2273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -2809,7 +2789,7 @@ dependencies = [
"gobject-sys 0.21.5", "gobject-sys 0.21.5",
"libc", "libc",
"system-deps 7.0.8", "system-deps 7.0.8",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -3363,7 +3343,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.62.2", "windows-core 0.61.2",
] ]
[[package]] [[package]]
@@ -4051,9 +4031,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.22.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
dependencies = [ dependencies = [
"sha2 0.11.1", "sha2 0.11.1",
] ]
@@ -4234,7 +4214,7 @@ dependencies = [
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4401,7 +4381,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4878,7 +4858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -5543,7 +5523,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6047,7 +6027,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6060,7 +6040,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6119,7 +6099,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6720,6 +6700,18 @@ dependencies = [
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"serde",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.5" version = "0.6.5"
@@ -6727,7 +6719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -7076,9 +7068,9 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05"
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.37.1" version = "0.37.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
@@ -7563,11 +7555,11 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-updater" name = "tauri-plugin-updater"
version = "2.14.0" version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"dirs 7.0.0", "dirs 7.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
@@ -7592,7 +7584,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"zip 4.6.1", "zip",
] ]
[[package]] [[package]]
@@ -7793,10 +7785,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -8462,7 +8454,7 @@ dependencies = [
"png 0.18.1", "png 0.18.1",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -8493,14 +8485,6 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "tristr"
version = "0.1.0"
dependencies = [
"serde",
"specta",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -8549,9 +8533,9 @@ dependencies = [
[[package]] [[package]]
name = "typed-path" name = "typed-path"
version = "0.12.3" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]] [[package]]
name = "typeid" name = "typeid"
@@ -8579,7 +8563,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -9121,7 +9105,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -9142,7 +9126,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"windows-sys 0.61.2", "windows-sys 0.60.2",
"windows-version", "windows-version",
] ]
@@ -9717,7 +9701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -9758,11 +9742,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.57.0" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
@@ -9783,7 +9767,6 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
"once_cell",
"percent-encoding", "percent-encoding",
"raw-window-handle", "raw-window-handle",
"sha2 0.10.9", "sha2 0.10.9",
@@ -10050,21 +10033,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "4.6.1" version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zip"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [ dependencies = [
"aes", "aes",
"bzip2", "bzip2",
@@ -10083,6 +10054,7 @@ dependencies = [
"sha1 0.11.0", "sha1 0.11.0",
"time", "time",
"typed-path", "typed-path",
"windows-sys 0.61.2",
"zeroize", "zeroize",
"zopfli", "zopfli",
"zstd", "zstd",
+2 -1
View File
@@ -85,8 +85,9 @@ reqwest = { version = "0.13.5", features = [
] } ] }
rust_iso3166 = "0.2.0" rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
tristr = { path = "../tristr", default-features = false, features = ["serde", "specta"] }
serde_json = { version = "1.0.154" } serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" } bitflags = { version = "2.13.2" }
+23 -15
View File
@@ -1,33 +1,41 @@
## 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> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口 - 修复检测更新后又发布新版时,应用内更新失败
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details> </details>
<details> <details>
<summary><strong> ✨ 新增功能 </strong></summary> <summary><strong> ✨ 新增功能 </strong></summary>
- 新增虚拟网卡设置的「DNS 模式」选项,支持配置 mihomo 系统 DNS 行为
</details> </details>
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮
**🖥️ Windows**
- Windows 系统代理默认绕过 `*.local` 域名,访问局域网设备的 `.local` 主机名时不再经过代理
</details> </details>
+4
View File
@@ -102,6 +102,10 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details.
To run the development server, execute the following commands after all prerequisites for **Tauri** are installed: To run the development server, execute the following commands after all prerequisites for **Tauri** are installed:
`pnpm dev` also requires a matching `clash-verge-service-ipc` source checkout at
`../clash-verge-service-ipc`; `pnpm prebuild` downloads binaries but does not
provide this checkout. See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup details.
```shell ```shell
pnpm i pnpm i
pnpm run prebuild pnpm run prebuild
@@ -11,6 +11,7 @@ mod chatgpt;
mod claude; mod claude;
mod disney_plus; mod disney_plus;
mod gemini; mod gemini;
mod muse;
mod netflix; mod netflix;
mod prime_video; mod prime_video;
mod spotify; mod spotify;
@@ -30,6 +31,7 @@ enum UnlockCheck {
ChatGptWeb, ChatGptWeb,
Claude, Claude,
Gemini, Gemini,
MuseAi,
YouTubePremium, YouTubePremium,
BahamutAnime, BahamutAnime,
Netflix, Netflix,
@@ -46,6 +48,7 @@ impl UnlockCheck {
Self::ChatGptWeb, Self::ChatGptWeb,
Self::Claude, Self::Claude,
Self::Gemini, Self::Gemini,
Self::MuseAi,
Self::YouTubePremium, Self::YouTubePremium,
Self::BahamutAnime, Self::BahamutAnime,
Self::Netflix, Self::Netflix,
@@ -66,6 +69,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME, Self::ChatGptWeb => chatgpt::CHATGPT_WEB_NAME,
Self::Claude => claude::CLAUDE_NAME, Self::Claude => claude::CLAUDE_NAME,
Self::Gemini => gemini::GEMINI_NAME, Self::Gemini => gemini::GEMINI_NAME,
Self::MuseAi => muse::MUSE_AI_NAME,
Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME, Self::YouTubePremium => youtube::YOUTUBE_PREMIUM_NAME,
Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME, Self::BahamutAnime => bahamut::BAHAMUT_ANIME_NAME,
Self::Netflix => netflix::NETFLIX_NAME, Self::Netflix => netflix::NETFLIX_NAME,
@@ -83,6 +87,7 @@ impl UnlockCheck {
Self::ChatGptWeb => chatgpt::check_chatgpt(client).await, Self::ChatGptWeb => chatgpt::check_chatgpt(client).await,
Self::Claude => claude::check_claude(client).await, Self::Claude => claude::check_claude(client).await,
Self::Gemini => gemini::check_gemini(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::YouTubePremium => youtube::check_youtube_premium(client).await,
Self::BahamutAnime => bahamut::check_bahamut_anime(client).await, Self::BahamutAnime => bahamut::check_bahamut_anime(client).await,
Self::Netflix => netflix::check_netflix(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)
}
+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 ## v2.5.8
<details> <details>
+8
View File
@@ -129,6 +129,7 @@ export default defineConfig([
'eslint.config.ts', 'eslint.config.ts',
`vite.config.mts`, `vite.config.mts`,
'scripts/*.mjs', 'scripts/*.mjs',
'scripts-workflow/*.mjs',
'tests/*.ts', 'tests/*.ts',
'src/polyfills/*.js', 'src/polyfills/*.js',
'tests/*.mjs', 'tests/*.mjs',
@@ -238,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}'], files: ['scripts/perf/*.{mjs,mts}'],
languageOptions: { languageOptions: {
+11 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "clash-verge", "name": "clash-verge",
"version": "2.5.9", "version": "2.6.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {
"prepare": "husky || true", "prepare": "husky || true",
@@ -14,7 +14,7 @@
"build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release", "build:fast": "cross-env NODE_OPTIONS='--max-old-space-size=4096' tauri build -- --profile fast-release",
"tauri": "tauri", "tauri": "tauri",
"web:dev": "vite", "web:dev": "vite",
"web:build": "tsc --noEmit && vite build", "web:build": "tsrs -p . --noEmit && vite build",
"web:serve": "vite preview", "web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs", "prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs", "updater": "node scripts/updater.mjs",
@@ -32,7 +32,7 @@
"i18n:types": "node scripts/generate-i18n-keys.mjs", "i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs", "gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke", "arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit", "typecheck": "tsrs -p . --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"knip:check": "knip", "knip:check": "knip",
@@ -60,7 +60,7 @@
"@tauri-apps/plugin-fs": "2.7.1", "@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3", "@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2", "@tauri-apps/plugin-opener": "2.7.2",
"@tauri-apps/plugin-updater": "2.14.0", "@tauri-apps/plugin-updater": "2.14.1",
"ahooks": "^3.10.0", "ahooks": "^3.10.0",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
@@ -79,12 +79,11 @@
"react-dom": "19.3.1", "react-dom": "19.3.1",
"react-error-boundary": "6.1.6", "react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-i18next": "17.1.8", "react-i18next": "17.1.9",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.5.1", "swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
@@ -97,7 +96,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3", "@tauri-apps/cli": "2.12.3",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.6", "@types/node": "^26.0.0",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
@@ -118,23 +117,24 @@
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0", "eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6", "glob": "^14.0.0",
"globals": "^17.12.0", "globals": "^17.12.0",
"https-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"knip": "6.43.0", "knip": "6.44.1",
"lint-staged": "^17.5.1", "lint-staged": "^17.5.1",
"sass": "^1.104.1", "sass": "^1.104.1",
"tar": "^7.5.22", "tar": "^7.5.22",
"terser": "^5.51.2", "terser": "^5.51.2",
"tsrs": "0.20.0-ts7.1.0-beta",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.70.0", "typescript-eslint": "^8.70.0",
"vite": "^8.3.0", "vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"webdriverio": "10.1.0", "webdriverio": "10.1.0",
"ws": "8.22.0" "ws": "8.22.1"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,mjs}": [ "*.{ts,tsx,js,mjs}": [
@@ -146,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.15.0" "packageManager": "pnpm@12.16.1"
} }
+311 -394
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -10,3 +10,11 @@ allowBuilds:
unrs-resolver: true unrs-resolver: true
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- dayjs@1.11.21 - 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
+7
View File
@@ -79,6 +79,13 @@
"matchPackageNames": ["typescript"], "matchPackageNames": ["typescript"],
"allowedVersions": "<7.0.0 || >=7.1.0" "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", "description": "Prevent project from supply chain attack",
"matchDatasources": ["npm"], "matchDatasources": ["npm"],
+4 -1
View File
@@ -41,7 +41,10 @@ fi
tmpfile=$(mktemp) 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
echo "" echo ""
if [ -f "$HISTORY" ]; then 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()
+1 -1
View File
@@ -17,7 +17,7 @@
"pnpm-lock.yaml", "pnpm-lock.yaml",
"pnpm-workspace.yaml", "pnpm-workspace.yaml",
"scripts/contract-inputs.json", "scripts/contract-inputs.json",
".github/workflows/frontend-check.yml", ".github/workflows/contract-parity.yml",
".husky/pre-push" ".husky/pre-push"
] ]
} }
+6 -7
View File
@@ -266,13 +266,13 @@ async function getLatestReleaseVersion() {
} }
if (!META_MAP[`${platform}-${arch}`]) { 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}`]) { 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 name = META_ALPHA_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32' const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz' const urlExt = isWin ? 'zip' : 'gz'
@@ -285,7 +285,7 @@ function clashMetaAlpha() {
} }
} }
function clashMeta() { function mihomo() {
const name = META_MAP[`${platform}-${arch}`] const name = META_MAP[`${platform}-${arch}`]
const isWin = platform === 'win32' const isWin = platform === 'win32'
const urlExt = isWin ? 'zip' : 'gz' const urlExt = isWin ? 'zip' : 'gz'
@@ -733,13 +733,12 @@ const tasks = [
{ {
name: 'verge-mihomo-alpha', name: 'verge-mihomo-alpha',
func: () => func: () =>
getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())), getLatestAlphaVersion().then(() => resolveSidecar(mihomoAlpha())),
retry: 5, retry: 5,
}, },
{ {
name: 'verge-mihomo', name: 'verge-mihomo',
func: () => func: () => getLatestReleaseVersion().then(() => resolveSidecar(mihomo())),
getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())),
retry: 5, retry: 5,
}, },
// After both sidecar tasks: it hashes what they downloaded. // After both sidecar tasks: it hashes what they downloaded.
+5 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clash-verge" name = "clash-verge"
version = "2.5.9" version = "2.6.0"
description = "clash verge" description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"] authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -52,9 +52,9 @@ anyhow = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
log = { workspace = true } log = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
tristr = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml_ng = { workspace = true } serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true } bitflags = { workspace = true }
socket2 = { workspace = true } socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] } warp = { version = "0.4.3", features = ["server"] }
@@ -79,12 +79,11 @@ tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0" tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.2" tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "2.5.0"
zip = "9.0.4" zip = "9.1.3"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
base64 = "0.23.1" base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11.1" sha2 = "0.11.1"
getrandom = "0.4.3" getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
@@ -98,7 +97,7 @@ tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true } tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.2"
@@ -154,7 +153,7 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.7.0" tauri-plugin-autostart = "2.7.0"
tauri-plugin-global-shortcut = "2.4.1" tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.0" tauri-plugin-updater = "2.14.1"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } criterion = { workspace = true }
+8 -3
View File
@@ -21,6 +21,7 @@ ManifestDPIAwareness PerMonitorV2
!include "FileAssociation.nsh" !include "FileAssociation.nsh"
!include "Win\COM.nsh" !include "Win\COM.nsh"
!include "Win\Propkey.nsh" !include "Win\Propkey.nsh"
!include "Win\RestartManager.nsh"
!include "WinVer.nsh" !include "WinVer.nsh"
!include "LogicLib.nsh" !include "LogicLib.nsh"
!include "StrFunc.nsh" !include "StrFunc.nsh"
@@ -332,6 +333,8 @@ Function PageLeaveReinstall
${EndIf} ${EndIf}
reinst_uninstall: reinst_uninstall:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
HideWindow HideWindow
ClearErrors ClearErrors
@@ -368,7 +371,10 @@ Function PageLeaveReinstall
MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)"
Abort Abort
${EndIf} ${EndIf}
Return
reinst_done: reinst_done:
!insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
FunctionEnd FunctionEnd
; 5. Choose install directory page ; 5. Choose install directory page
@@ -901,8 +907,7 @@ Section Install
nsExec::Exec 'netsh int tcp res' nsExec::Exec 'netsh int tcp res'
!include "Win\RestartManager.nsh" !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses !insertmacro CheckAllVergeProcesses
; Ensure startup folders exist ; Ensure startup folders exist
@@ -1101,7 +1106,7 @@ Section Uninstall
!insertmacro NSIS_HOOK_PREUNINSTALL !insertmacro NSIS_HOOK_PREUNINSTALL
!endif !endif
!insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}"
!insertmacro CheckAllVergeProcesses !insertmacro CheckAllVergeProcesses
!insertmacro RemoveVergeService !insertmacro RemoveVergeService
+18 -13
View File
@@ -1,12 +1,15 @@
use super::CmdResult; use super::CmdResult;
use crate::{ use crate::{
cmd::StringifyErr as _, cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent}, core::{
SilentUpdater,
updater::{DownloadEvent, InstallUpdateOutcome},
},
feat, feat,
utils::dirs, utils::dirs,
}; };
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _, ipc::Channel}; use tauri::{AppHandle, Manager as _, ipc::Channel};
use tristr::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -59,10 +62,9 @@ pub async fn restart_app() -> CmdResult<()> {
#[specta::specta] #[specta::specta]
pub async fn install_update( pub async fn install_update(
app_handle: AppHandle, app_handle: AppHandle,
version: String, version: std::string::String,
on_event: Channel<DownloadEvent>, on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> { ) -> CmdResult<InstallUpdateOutcome> {
let version: String = version;
SilentUpdater::global() SilentUpdater::global()
.install_update(&app_handle, &version, |event| { .install_update(&app_handle, &version, |event| {
let _ = on_event.send(event); let _ = on_event.send(event);
@@ -79,22 +81,25 @@ pub fn cancel_update_download() {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_app_dir() -> CmdResult<String> { pub fn get_app_dir() -> CmdResult<std::string::String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
Ok(app_home_dir) Ok(app_home_dir)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String> { pub async fn download_icon_cache(
let url: String = url; url: std::string::String,
let name: String = name; name: std::string::String,
feat::download_icon_cache(url, name).await ) -> CmdResult<std::string::String> {
let url: String = url.into();
let name: String = name.into();
feat::download_icon_cache(url, name).await.map(Into::into)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult<String> { pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult<std::string::String> {
let path: String = path; let path: String = path.into();
feat::copy_icon_file(path, icon_info).await feat::copy_icon_file(path, icon_info).await.map(Into::into)
} }
+11 -11
View File
@@ -1,7 +1,7 @@
use super::CmdResult; use super::CmdResult;
use crate::{cmd::StringifyErr as _, feat}; use crate::{cmd::StringifyErr as _, feat};
use feat::LocalBackupFile; use feat::LocalBackupFile;
use tristr::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -17,15 +17,15 @@ pub async fn list_local_backup() -> CmdResult<Vec<LocalBackupFile>> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn delete_local_backup(filename: String) -> CmdResult<()> { pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename; let filename: String = filename.into();
feat::delete_local_backup(filename).await.stringify_err() feat::delete_local_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn restore_local_backup(filename: String) -> CmdResult<()> { pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename; let filename: String = filename.into();
feat::restore_local_backup(filename) feat::restore_local_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
@@ -33,15 +33,15 @@ pub async fn restore_local_backup(filename: String) -> CmdResult<()> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn import_local_backup(source: String) -> CmdResult<String> { pub async fn import_local_backup(source: std::string::String) -> CmdResult<std::string::String> {
let source: String = source; let source: String = source.into();
feat::import_local_backup(source).await.stringify_err() feat::import_local_backup(source).await.map(Into::into).stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> {
let filename: String = filename; let filename: String = filename.into();
let destination: String = destination; let destination: String = destination.into();
feat::export_local_backup(filename, destination).await.stringify_err() feat::export_local_backup(filename, destination).await.stringify_err()
} }
+16 -17
View File
@@ -12,8 +12,8 @@ use crate::{
}, },
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use smartstring::alias::String;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -37,8 +37,8 @@ pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn patch_clash_mode(payload: String) -> CmdResult { pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult {
let payload: String = payload; let payload: String = payload.into();
feat::change_clash_mode(payload) feat::change_clash_mode(payload)
.await .await
.with_error_code("CLASH_MODE_UPDATE_FAILED") .with_error_code("CLASH_MODE_UPDATE_FAILED")
@@ -47,14 +47,14 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult {
/// Reads the saved mode without depending on strict mihomo `/configs` deserialization. /// Reads the saved mode without depending on strict mihomo `/configs` deserialization.
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_clash_mode() -> CmdResult<Option<String>> { pub async fn get_clash_mode() -> CmdResult<Option<std::string::String>> {
Ok(Config::clash().await.data_arc().get_mode()) Ok(Config::clash().await.data_arc().get_mode())
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn change_clash_core(clash_core: String) -> CmdResult<Option<CommandFailure>> { pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult<Option<CommandFailure>> {
let clash_core: String = clash_core; let clash_core: String = clash_core.into();
logging!(info, Type::Config, "changing core to {clash_core}"); logging!(info, Type::Config, "changing core to {clash_core}");
match CoreManager::global().change_core(&clash_core).await { match CoreManager::global().change_core(&clash_core).await {
@@ -112,8 +112,8 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult<feat::CoreUpgradeRepor
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn test_delay(url: String) -> CmdResult<u32> { pub async fn test_delay(url: std::string::String) -> CmdResult<u32> {
let url: String = url; let url: String = url.into();
let result = match feat::test_delay(url).await { let result = match feat::test_delay(url).await {
Ok(delay) => delay, Ok(delay) => delay,
Err(e) => { Err(e) => {
@@ -148,12 +148,12 @@ pub fn take_dns_override_notice() -> bool {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn set_dns_override( pub async fn set_dns_override(
profile_uid: String, profile_uid: std::string::String,
enabled: bool, enabled: bool,
confirmation: Option<String>, confirmation: Option<std::string::String>,
) -> CmdResult<feat::DnsOverrideOutcome> { ) -> CmdResult<feat::DnsOverrideOutcome> {
let profile_uid: String = profile_uid; let profile_uid: String = profile_uid.into();
let confirmation = confirmation; let confirmation = confirmation.map(String::from);
feat::set_dns_override(profile_uid, enabled, confirmation) feat::set_dns_override(profile_uid, enabled, confirmation)
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
@@ -205,21 +205,20 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
logging!(info, Type::Config, "Config regenerated successfully"); logging!(info, Type::Config, "Config regenerated successfully");
} }
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_dns_config_content() -> CmdResult<Option<String>> { pub async fn get_dns_config_content() -> CmdResult<Option<std::string::String>> {
use crate::utils::dirs; use crate::utils::dirs;
use tokio::fs; use tokio::fs;
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
match fs::read_to_string(&dns_path).await { match fs::read_to_string(&dns_path).await {
Ok(content) => Ok(Some(content.into())), Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(CommandFailure::plain(error)), Err(error) => Err(CommandFailure::plain(error)),
} }
@@ -244,7 +243,7 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_clash_logs() -> CmdResult<Vec<String>> { pub async fn get_clash_logs() -> CmdResult<Vec<std::string::String>> {
let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default(); let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default();
Ok(logs.into_iter().collect()) Ok(logs)
} }
@@ -2,7 +2,6 @@ pub use clash_verge_media_unlock::UnlockItem;
use reqwest::{Client, Proxy}; use reqwest::{Client, Proxy};
use std::time::Duration; use std::time::Duration;
use tauri::{command, ipc::Channel}; use tauri::{command, ipc::Channel};
use tristr::alias::String;
#[command] #[command]
#[specta::specta] #[specta::specta]
@@ -24,7 +23,7 @@ async fn create_client() -> Result<Client, String> {
.cookie_store(true) .cookie_store(true)
.proxy(proxy) .proxy(proxy)
.build() .build()
.map_err(|error| format!("创建HTTP客户端失败: {error}").into()) .map_err(|error| format!("创建HTTP客户端失败: {error}"))
} }
#[command] #[command]
@@ -41,7 +40,5 @@ pub async fn check_media_unlock(on_complete: Channel<UnlockItem>) -> Result<Vec<
#[specta::specta] #[specta::specta]
pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> { pub async fn check_media_unlock_item(name: String) -> Result<UnlockItem, String> {
let client = create_client().await?; let client = create_client().await?;
clash_verge_media_unlock::check_media_unlock_item(&client, &name) clash_verge_media_unlock::check_media_unlock_item(&client, &name).await
.await
.map_err(Into::into)
} }
+16 -6
View File
@@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use tristr::alias::String; use smartstring::alias::String;
pub type CmdResult<T = ()> = Result<T, CommandFailure>; pub type CmdResult<T = ()> = Result<T, CommandFailure>;
@@ -8,9 +8,9 @@ pub type CmdResult<T = ()> = Result<T, CommandFailure>;
pub struct CommandFailure { pub struct CommandFailure {
/// Stable frontend identifier, when classified. /// Stable frontend identifier, when classified.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>, pub code: Option<std::string::String>,
/// Human-readable diagnostic detail. /// Human-readable diagnostic detail.
pub detail: String, pub detail: std::string::String,
/// Requested operation when known by the command. /// Requested operation when known by the command.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<crate::core::notification::FailedOperation>, pub operation: Option<crate::core::notification::FailedOperation>,
@@ -20,8 +20,8 @@ impl CommandFailure {
/// A failure with a stable code. /// A failure with a stable code.
pub fn coded(code: &str, detail: impl std::fmt::Display) -> Self { pub fn coded(code: &str, detail: impl std::fmt::Display) -> Self {
Self { Self {
code: Some(code.into()), code: Some(code.to_owned()),
detail: detail.to_string().into(), detail: detail.to_string(),
operation: None, operation: None,
} }
} }
@@ -30,7 +30,7 @@ impl CommandFailure {
pub fn plain(detail: impl std::fmt::Display) -> Self { pub fn plain(detail: impl std::fmt::Display) -> Self {
Self { Self {
code: None, code: None,
detail: detail.to_string().into(), detail: detail.to_string(),
operation: None, operation: None,
} }
} }
@@ -50,6 +50,16 @@ impl From<&str> for CommandFailure {
} }
} }
impl From<std::string::String> for CommandFailure {
fn from(detail: std::string::String) -> Self {
Self {
code: None,
detail,
operation: None,
}
}
}
impl From<String> for CommandFailure { impl From<String> for CommandFailure {
fn from(detail: String) -> Self { fn from(detail: String) -> Self {
Self::plain(detail) Self::plain(detail)
+6 -13
View File
@@ -5,7 +5,6 @@ use crate::core::{proxy_control, sysopt::Sysopt};
use gethostname::gethostname; use gethostname::gethostname;
use sysproxy::{Autoproxy, Sysproxy}; use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo; use tauri_plugin_clash_verge_sysinfo;
use tristr::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -25,8 +24,8 @@ pub async fn get_sys_proxy() -> CmdResult<SystemProxy> {
Ok(SystemProxy { Ok(SystemProxy {
enable, enable,
server: format!("{host}:{port}").into(), server: format!("{host}:{port}"),
bypass: bypass.into(), bypass,
}) })
} }
@@ -40,10 +39,7 @@ pub async fn get_auto_proxy() -> CmdResult<AutoProxy> {
}; };
let Autoproxy { enable, url } = auto_proxy; let Autoproxy { enable, url } = auto_proxy;
Ok(AutoProxy { Ok(AutoProxy { enable, url })
enable,
url: url.into(),
})
} }
#[tauri::command] #[tauri::command]
@@ -56,10 +52,10 @@ pub fn get_embedded_server_port() -> CmdResult<u16> {
#[specta::specta] #[specta::specta]
pub fn get_system_hostname() -> String { pub fn get_system_hostname() -> String {
match gethostname().into_string() { match gethostname().into_string() {
Ok(name) => name.into(), Ok(name) => name,
Err(os_string) => { Err(os_string) => {
let fallback = format!("{os_string:?}"); let fallback = format!("{os_string:?}");
fallback.trim_matches('"').into() fallback.trim_matches('"').to_string()
} }
} }
} }
@@ -68,9 +64,6 @@ pub fn get_system_hostname() -> String {
#[specta::specta] #[specta::specta]
pub fn get_network_interfaces() -> Vec<String> { pub fn get_network_interfaces() -> Vec<String> {
tauri_plugin_clash_verge_sysinfo::list_network_interfaces() tauri_plugin_clash_verge_sysinfo::list_network_interfaces()
.into_iter()
.map(Into::into)
.collect()
} }
#[tauri::command] #[tauri::command]
@@ -84,7 +77,7 @@ pub fn get_network_interfaces_info() -> CmdResult<Vec<crate::ipc::NetworkInterfa
let mut result = Vec::new(); let mut result = Vec::new();
for interface in interfaces { for interface in interfaces {
if names.iter().any(|name| name == &interface.name) { if names.contains(&interface.name) {
result.push(interface.into()); result.push(interface.into());
} }
} }
+31 -26
View File
@@ -19,17 +19,17 @@ use crate::{
use clash_verge_draft::{Draft, SharedDraft}; use clash_verge_draft::{Draft, SharedDraft};
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use scopeguard::defer; use scopeguard::defer;
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tristr::alias::String;
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false); static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
fn profile_import_error(err: &anyhow::Error) -> String { fn profile_import_error(err: &anyhow::Error) -> std::string::String {
if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) { if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) {
return cause.to_string().into(); return cause.to_string();
} }
format!("导入订阅失败: {err:#}").into() format!("导入订阅失败: {err:#}")
} }
#[tauri::command] #[tauri::command]
@@ -68,7 +68,7 @@ pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
#[tauri::command] #[tauri::command]
#[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))] #[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))]
#[specta::specta] #[specta::specta]
pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult { pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
Ok(it) => it, Ok(it) => it,
Err(e) => { Err(e) => {
@@ -98,9 +98,9 @@ pub async fn import_profile(url: String, option: Option<PrfOption>) -> CmdResult
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult {
let active_id: String = active_id; let active_id: String = active_id.into();
let over_id: String = over_id; let over_id: String = over_id.into();
match profiles_reorder_safe(&active_id, &over_id).await { match profiles_reorder_safe(&active_id, &over_id).await {
Ok(_) => { Ok(_) => {
logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id); logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id);
@@ -121,9 +121,9 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn create_profile(item: ProfileCreate, file_data: Option<String>) -> CmdResult { pub async fn create_profile(item: ProfileCreate, file_data: Option<std::string::String>) -> CmdResult {
let item: PrfItem = item.into(); let item: PrfItem = item.into();
let file_data = file_data; let file_data = file_data.map(String::from);
match profiles_append_item_with_filedata_safe(&item, file_data).await { match profiles_append_item_with_filedata_safe(&item, file_data).await {
Ok(_) => { Ok(_) => {
profiles_save_file_safe() profiles_save_file_safe()
@@ -138,8 +138,8 @@ pub async fn create_profile(item: ProfileCreate, file_data: Option<String>) -> C
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult { pub async fn update_profile(index: std::string::String, option: Option<PrfOption>) -> CmdResult {
let index: String = index; let index: String = index.into();
match feat::update_profile(&index, option.as_ref(), true).await { match feat::update_profile(&index, option.as_ref(), true).await {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => { Err(e) => {
@@ -151,8 +151,8 @@ pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResu
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn delete_profile(index: String) -> CmdResult { pub async fn delete_profile(index: std::string::String) -> CmdResult {
let index: String = index; let index: String = index.into();
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
@@ -191,10 +191,6 @@ pub async fn delete_profile(index: String) -> CmdResult {
drop(config_update_guard); drop(config_update_guard);
drop(profile_write_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 { if let Err(e) = Tray::global().update_tooltip().await {
logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}"); logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}");
} }
@@ -264,7 +260,6 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationO
handle::Handle::notify_profile_changed(current); handle::Handle::notify_profile_changed(current);
} }
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(ValidationOutcome::Valid) Ok(ValidationOutcome::Valid)
} }
@@ -325,6 +320,7 @@ async fn perform_config_update(
#[derive(Default, serde::Deserialize, specta::Type)] #[derive(Default, serde::Deserialize, specta::Type)]
#[serde(default)] #[serde(default)]
pub struct ProfileSelection { pub struct ProfileSelection {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>, pub current: Option<String>,
} }
@@ -332,9 +328,13 @@ pub struct ProfileSelection {
#[serde(default)] #[serde(default)]
pub struct ProfileCreate { pub struct ProfileCreate {
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>, pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>, pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
} }
@@ -356,10 +356,15 @@ impl From<ProfileCreate> for PrfItem {
#[serde(default)] #[serde(default)]
pub struct ProfilePatch { pub struct ProfilePatch {
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>, pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>, pub desc: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>, pub file: Option<String>,
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>, pub selected: Option<Vec<crate::config::PrfSelected>>,
pub extra: Option<crate::config::PrfExtra>, pub extra: Option<crate::config::PrfExtra>,
@@ -426,9 +431,9 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn patch_profile(index: String, profile: ProfilePatch) -> CmdResult { pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult {
let profile: PrfItem = profile.into(); let profile: PrfItem = profile.into();
let index: String = index; let index: String = index.into();
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
&& let Some(new_option) = profile.option.as_ref() && let Some(new_option) = profile.option.as_ref()
@@ -469,7 +474,7 @@ pub async fn patch_profile(index: String, profile: ProfilePatch) -> CmdResult {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn view_profile(index: String) -> CmdResult { pub async fn view_profile(index: std::string::String) -> CmdResult {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
let file = profiles_ref let file = profiles_ref
@@ -494,7 +499,7 @@ pub async fn view_profile(index: String) -> CmdResult {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn read_profile_file(index: String) -> CmdResult<String> { pub async fn read_profile_file(index: std::string::String) -> CmdResult<std::string::String> {
let item = { let item = {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
let profiles_ref = profiles.latest_arc(); let profiles_ref = profiles.latest_arc();
@@ -514,7 +519,7 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
.join(file.as_str()); .join(file.as_str());
match tokio::fs::try_exists(&path).await { match tokio::fs::try_exists(&path).await {
Ok(true) => {} Ok(true) => {}
Ok(false) => return Ok(String::new()), Ok(false) => return Ok(std::string::String::new()),
Err(err) => { Err(err) => {
return Err(coded_error( return Err(coded_error(
"PROFILE_READ_FAILED", "PROFILE_READ_FAILED",
@@ -525,12 +530,12 @@ pub async fn read_profile_file(index: String) -> CmdResult<String> {
} }
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?; let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
Ok(data) Ok(data.into())
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> { pub async fn get_next_update_time(uid: std::string::String) -> CmdResult<Option<i64>> {
let timer = Timer::global(); let timer = Timer::global();
let next_time = timer.get_next_update_time(&uid).await; let next_time = timer.get_next_update_time(&uid).await;
Ok(next_time) Ok(next_time)
+5 -6
View File
@@ -15,7 +15,6 @@ use std::{
collections::{BTreeMap, HashSet}, collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
}; };
use tristr::alias::String;
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites. /// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command] #[tauri::command]
@@ -49,7 +48,7 @@ fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
.filter_map(|name| name.as_str()) .filter_map(|name| name.as_str())
.filter(|name| !name.is_empty() && *name != "GLOBAL") .filter(|name| !name.is_empty() && *name != "GLOBAL")
.filter(|name| seen.insert(*name)) .filter(|name| seen.insert(*name))
.map(String::from) .map(str::to_owned)
.collect() .collect()
} }
@@ -64,7 +63,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.into_iter() .into_iter()
.flat_map(|providers| providers.keys()) .flat_map(|providers| providers.keys())
.filter_map(|name| name.as_str()) .filter_map(|name| name.as_str())
.map(String::from) .map(str::to_owned)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
all_providers.sort(); all_providers.sort();
let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true); let flag = |group: &Mapping, key: &str| group.get(key).and_then(|value| value.as_bool()) == Some(true);
@@ -76,7 +75,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.flatten() .flatten()
.filter_map(|group| group.as_mapping()) .filter_map(|group| group.as_mapping())
.filter_map(|group| { .filter_map(|group| {
let name: String = group.get("name")?.as_str()?.into(); let name = group.get("name")?.as_str()?.to_owned();
let providers = if flag(group, "include-all") || flag(group, "include-all-providers") { let providers = if flag(group, "include-all") || flag(group, "include-all-providers") {
all_providers.clone() all_providers.clone()
} else { } else {
@@ -86,14 +85,14 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
.into_iter() .into_iter()
.flatten() .flatten()
.filter_map(|provider| provider.as_str()) .filter_map(|provider| provider.as_str())
.map(String::from) .map(str::to_owned)
.collect() .collect()
}; };
let exclude_types = group let exclude_types = group
.get("exclude-type") .get("exclude-type")
.and_then(|types| types.as_str()) .and_then(|types| types.as_str())
.filter(|types| !types.is_empty()) .filter(|types| !types.is_empty())
.map(|types| types.split('|').map(String::from).collect()) .map(|types| types.split('|').map(str::to_owned).collect())
.unwrap_or_default(); .unwrap_or_default();
Some(( Some((
name, name,
+11 -6
View File
@@ -1,8 +1,8 @@
use super::CmdResult; use super::CmdResult;
use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter}; use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter};
use anyhow::{Context as _, anyhow}; use anyhow::{Context as _, anyhow};
use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
use tristr::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -17,7 +17,7 @@ pub async fn get_runtime_config() -> CmdResult<Option<crate::ipc::YamlMapping>>
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_runtime_yaml() -> CmdResult<String> { pub async fn get_runtime_yaml() -> CmdResult<std::string::String> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
@@ -36,17 +36,22 @@ pub async fn get_runtime_logs() -> CmdResult<RuntimeLogs> {
#[derive(serde::Serialize, specta::Type)] #[derive(serde::Serialize, specta::Type)]
#[serde(transparent)] #[serde(transparent)]
pub struct RuntimeLogs(HashMap<String, Vec<(String, String)>>); pub struct RuntimeLogs(
#[specta(type = HashMap<std::string::String, Vec<(std::string::String, std::string::String)>>)]
HashMap<String, Vec<(String, String)>>,
);
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn take_discarded_keys_notice() -> Option<String> { pub fn take_discarded_keys_notice() -> Option<std::string::String> {
crate::enhance::take_discarded_keys_notice() crate::enhance::take_discarded_keys_notice().map(Into::into)
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> { 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 = Config::runtime().await;
let runtime = runtime.latest_arc(); let runtime = runtime.latest_arc();
+11 -9
View File
@@ -1,4 +1,4 @@
use super::{CmdResult, CommandFailure}; use super::CmdResult;
use crate::{ use crate::{
cmd::StringifyErr as _, cmd::StringifyErr as _,
cmd::validate::{ValidationNoticeTarget, handle_validation_notice}, cmd::validate::{ValidationNoticeTarget, handle_validation_notice},
@@ -10,16 +10,19 @@ use crate::{
}, },
utils::dirs, utils::dirs,
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
/// 保存profiles的配置 /// 保存profiles的配置
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult<ValidationOutcome> { pub async fn save_profile_file(
let index: String = index; index: std::string::String,
let file_data = file_data; file_data: Option<std::string::String>,
) -> CmdResult<ValidationOutcome> {
let index: String = index.into();
let file_data = file_data.map(String::from);
let file_data = match file_data { let file_data = match file_data {
Some(d) => d, Some(d) => d,
None => return Ok(ValidationOutcome::Valid), None => return Ok(ValidationOutcome::Valid),
@@ -150,7 +153,7 @@ async fn handle_saved_profile_file(
Err(e) => { Err(e) => {
logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {e:#}"); logging!(error, Type::Config, "[cmd配置save] 验证过程发生错误: {e:#}");
restore_original(file_path, original_content, original_existed).await?; restore_original(file_path, original_content, original_existed).await?;
return Err(CommandFailure::plain(e)); return Err(e.to_string().into());
} }
} }
@@ -165,7 +168,6 @@ async fn handle_saved_profile_file(
); );
match CoreManager::global().update_config_forced().await { match CoreManager::global().update_config_forced().await {
Ok(outcome) if outcome.is_valid() => { Ok(outcome) if outcome.is_valid() => {
logging_error!(Type::Config, Config::sync_dns_override().await);
handle::Handle::refresh_clash(); handle::Handle::refresh_clash();
Ok(ValidationOutcome::Valid) Ok(ValidationOutcome::Valid)
} }
@@ -178,7 +180,7 @@ async fn handle_saved_profile_file(
Err(err) => { Err(err) => {
logging!(error, Type::Config, "[cmd配置save] 运行时配置应用错误: {err:#}"); logging!(error, Type::Config, "[cmd配置save] 运行时配置应用错误: {err:#}");
restore_original(file_path, original_content, original_existed).await?; restore_original(file_path, original_content, original_existed).await?;
Err(CommandFailure::plain(err)) Err(err.to_string().into())
} }
} }
} }
+3 -4
View File
@@ -7,7 +7,6 @@ use crate::{
service::{SERVICE_MANAGER, ServiceStatus, request_runtime_provider_sync}, service::{SERVICE_MANAGER, ServiceStatus, request_runtime_provider_sync},
}, },
}; };
use tristr::alias::String;
#[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)] #[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
@@ -75,7 +74,7 @@ where
if reasons.is_empty() { if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code)); return Err(proxy_aware_coded_error(&error, error_code));
} }
let reason: String = reasons.join("\n").into(); let reason = reasons.join("\n");
fallback(reason.clone()) fallback(reason.clone())
.await .await
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?; .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
@@ -180,7 +179,7 @@ mod tests {
let error = match availability { let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError { Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus { status: InstallationStatus {
service_sha256: std::string::String::new(), service_sha256: String::new(),
protocol: ProtocolInfo::current(), protocol: ProtocolInfo::current(),
cores: vec![CoreInspection { cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(), name: "verge-mihomo.exe".into(),
@@ -204,7 +203,7 @@ mod tests {
assert_eq!( assert_eq!(
result, result,
Ok(ServiceInstallOutcome::Sidecar { Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}").into() reason: format!("verge-mihomo.exe: {reason}")
}) })
); );
} else { } else {
-1
View File
@@ -2,7 +2,6 @@ use crate::core::{
notification::{self, PendingFailure}, notification::{self, PendingFailure},
runstate::{RUN_STATE, RunStateView}, runstate::{RUN_STATE, RunStateView},
}; };
use tristr::alias::String;
/// Returns one coherent core/service snapshot instead of independently refreshed state. /// Returns one coherent core/service snapshot instead of independently refreshed state.
#[tauri::command] #[tauri::command]
+13 -9
View File
@@ -4,14 +4,18 @@ use crate::{
config::{Config, IVerge}, config::{Config, IVerge},
core, feat, core, feat,
}; };
use tristr::alias::String; use smartstring::alias::String;
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { pub async fn save_webdav_config(
let url: String = url; url: std::string::String,
let username: String = username; username: std::string::String,
let password: String = password; password: std::string::String,
) -> CmdResult<()> {
let url: String = url.into();
let username: String = username.into();
let password: String = password.into();
let patch = IVerge { let patch = IVerge {
webdav_url: Some(url), webdav_url: Some(url),
webdav_username: Some(username), webdav_username: Some(username),
@@ -44,15 +48,15 @@ pub async fn list_webdav_backup() -> CmdResult<Vec<crate::ipc::WebDavFile>> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename; let filename: String = filename.into();
feat::delete_webdav_backup(filename).await.stringify_err() feat::delete_webdav_backup(filename).await.stringify_err()
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> {
let filename: String = filename; let filename: String = filename.into();
feat::restore_webdav_backup(filename) feat::restore_webdav_backup(filename)
.await .await
.map_err(|error| super::proxy_aware_error(&error)) .map_err(|error| super::proxy_aware_error(&error))
+24 -74
View File
@@ -10,7 +10,6 @@ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
str::FromStr as _, str::FromStr as _,
}; };
use tristr::alias::String;
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct IClashTemp(pub Mapping); pub struct IClashTemp(pub Mapping);
@@ -85,12 +84,12 @@ impl IClashTemp {
#[cfg(unix)] #[cfg(unix)]
map.insert( map.insert(
"external-controller-unix".into(), "external-controller-unix".into(),
Self::guard_external_controller_ipc().as_str().into(), Self::guard_external_controller_ipc().into(),
); );
#[cfg(windows)] #[cfg(windows)]
map.insert( map.insert(
"external-controller-pipe".into(), "external-controller-pipe".into(),
Self::guard_external_controller_ipc().as_str().into(), Self::guard_external_controller_ipc().into(),
); );
map.insert("tun".into(), tun_config.into()); map.insert("tun".into(), tun_config.into());
cors_map.insert("allow-private-network".into(), true.into()); cors_map.insert("allow-private-network".into(), true.into());
@@ -135,18 +134,12 @@ impl IClashTemp {
config.insert("mixed-port".into(), mixed_port.into()); config.insert("mixed-port".into(), mixed_port.into());
config.insert("socks-port".into(), socks_port.into()); config.insert("socks-port".into(), socks_port.into());
config.insert("port".into(), port.into()); config.insert("port".into(), port.into());
config.insert("external-controller".into(), ctrl.as_str().into()); config.insert("external-controller".into(), ctrl.into());
#[cfg(unix)] #[cfg(unix)]
config.insert( config.insert("external-controller-unix".into(), external_controller_unix.into());
"external-controller-unix".into(),
external_controller_unix.as_str().into(),
);
#[cfg(windows)] #[cfg(windows)]
config.insert( config.insert("external-controller-pipe".into(), external_controller_pipe.into());
"external-controller-pipe".into(),
external_controller_pipe.as_str().into(),
);
config config
} }
@@ -173,9 +166,9 @@ impl IClashTemp {
port: Self::guard_port(config), port: Self::guard_port(config),
server: Self::guard_client_ctrl(config), server: Self::guard_client_ctrl(config),
secret: config.get("secret").and_then(|value| match value { secret: config.get("secret").and_then(|value| match value {
Value::String(val_str) => Some(val_str.clone().into()), Value::String(val_str) => Some(val_str.clone()),
Value::Bool(val_bool) => Some(val_bool.to_string().into()), Value::Bool(val_bool) => Some(val_bool.to_string()),
Value::Number(val_num) => Some(val_num.to_string().into()), Value::Number(val_num) => Some(val_num.to_string()),
_ => None, _ => None,
}), }),
} }
@@ -187,88 +180,45 @@ impl IClashTemp {
/// 的严格 `BaseConfig` 反序列化,因此即使核心返回的字段与插件结构体不匹配也能取到。 /// 的严格 `BaseConfig` 反序列化,因此即使核心返回的字段与插件结构体不匹配也能取到。
pub fn get_mode(&self) -> Option<String> { pub fn get_mode(&self) -> Option<String> {
self.0.get("mode").and_then(|value| match value { self.0.get("mode").and_then(|value| match value {
Value::String(val_str) => Some(val_str.clone().into()), Value::String(val_str) => Some(val_str.clone()),
_ => None, _ => None,
}) })
} }
#[cfg(not(target_os = "windows"))] fn guard_port_value(config: &Mapping, key: &str, default: u16) -> u16 {
fn guard_redir_port(config: &Mapping) -> u16 {
let mut port = config let mut port = config
.get("redir-port") .get(key)
.and_then(|value| match value { .and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(), Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16), Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None, _ => None,
}) })
.unwrap_or(7895); .unwrap_or(default);
if port == 0 { if port == 0 {
port = 7895; port = default;
} }
port port
} }
#[cfg(not(target_os = "windows"))]
fn guard_redir_port(config: &Mapping) -> u16 {
Self::guard_port_value(config, "redir-port", network::ports::DEFAULT_REDIR)
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn guard_tproxy_port(config: &Mapping) -> u16 { fn guard_tproxy_port(config: &Mapping) -> u16 {
let mut port = config Self::guard_port_value(config, "tproxy-port", network::ports::DEFAULT_TPROXY)
.get("tproxy-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_TPROXY);
if port == 0 {
port = network::ports::DEFAULT_TPROXY;
}
port
} }
fn guard_mixed_port(config: &Mapping) -> u16 { fn guard_mixed_port(config: &Mapping) -> u16 {
let raw_value = config.get("mixed-port"); Self::guard_port_value(config, "mixed-port", network::ports::DEFAULT_MIXED)
let mut port = raw_value
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(network::ports::DEFAULT_MIXED);
if port == 0 {
port = network::ports::DEFAULT_MIXED;
}
port
} }
fn guard_socks_port(config: &Mapping) -> u16 { fn guard_socks_port(config: &Mapping) -> u16 {
let mut port = config Self::guard_port_value(config, "socks-port", network::ports::DEFAULT_SOCKS)
.get("socks-port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7898);
if port == 0 {
port = 7898;
}
port
} }
fn guard_port(config: &Mapping) -> u16 { fn guard_port(config: &Mapping) -> u16 {
let mut port = config Self::guard_port_value(config, "port", network::ports::DEFAULT_HTTP)
.get("port")
.and_then(|value| match value {
Value::String(val_str) => val_str.parse().ok(),
Value::Number(val_num) => val_num.as_u64().map(|u| u as u16),
_ => None,
})
.unwrap_or(7899);
if port == 0 {
port = 7899;
}
port
} }
pub(super) fn guard_server_ctrl(config: &Mapping) -> String { pub(super) fn guard_server_ctrl(config: &Mapping) -> String {
@@ -283,7 +233,7 @@ impl IClashTemp {
false => Cow::Borrowed(val_str), false => Cow::Borrowed(val_str),
}; };
SocketAddr::from_str(&val).ok().map(|s| s.to_string().into()) SocketAddr::from_str(&val).ok().map(|s| s.to_string())
} }
None => None, None => None,
}) })
@@ -297,7 +247,7 @@ impl IClashTemp {
if socket.ip().is_unspecified() { if socket.ip().is_unspecified() {
socket.set_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); socket.set_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
} }
socket.to_string().into() socket.to_string()
} }
Err(_) => "127.0.0.1:9097".into(), Err(_) => "127.0.0.1:9097".into(),
} }
+3 -3
View File
@@ -20,10 +20,10 @@ use backon::{ExponentialBuilder, Retryable as _};
use clash_verge_draft::Draft; use clash_verge_draft::Draft;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::{collections::HashSet, path::PathBuf}; use std::{collections::HashSet, path::PathBuf};
use tokio::sync::{Mutex, MutexGuard, OnceCell}; use tokio::sync::{Mutex, MutexGuard, OnceCell};
use tokio::time::sleep; use tokio::time::sleep;
use tristr::alias::String;
pub(crate) struct Config { pub(crate) struct Config {
clash_config: Draft<IClashTemp>, clash_config: Draft<IClashTemp>,
@@ -205,7 +205,7 @@ impl Config {
Self::write_runtime_file(&yaml).await Self::write_runtime_file(&yaml).await
} }
pub(crate) async fn runtime_config_yaml() -> Result<String> { pub(crate) async fn runtime_config_yaml() -> Result<std::string::String> {
let runtime = Self::runtime().await; let runtime = Self::runtime().await;
AsyncHandler::spawn_blocking(move || { AsyncHandler::spawn_blocking(move || {
let runtime_lastest = runtime.latest_arc(); let runtime_lastest = runtime.latest_arc();
@@ -216,7 +216,7 @@ impl Config {
.or_else(|| runtime_data.config.as_ref()) .or_else(|| runtime_data.config.as_ref())
.ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?; .ok_or_else(|| anyhow!("failed to generate runtime config, might need to restart application"))?;
let yaml_str = serde_yaml_ng::to_string(config)?; let yaml_str = serde_yaml_ng::to_string(config)?;
Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str).into()) Ok(format!("# Generated by Clash Verge\n\n{}", yaml_str))
}) })
.await .await
.map_err(|join| anyhow!("runtime serialization task failed: {join}"))? .map_err(|join| anyhow!("runtime serialization task failed: {join}"))?
+2 -1
View File
@@ -4,8 +4,8 @@ use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
use smartstring::alias::String;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tristr::alias::String;
use super::{Config, IVerge}; use super::{Config, IVerge};
use crate::core::handle::Handle; use crate::core::handle::Handle;
@@ -110,6 +110,7 @@ impl DnsOverrideState {
pub struct ProfileDnsSettings { pub struct ProfileDnsSettings {
pub enabled: bool, pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub confirmation: Option<String>, pub confirmation: Option<String>,
} }
+2 -6
View File
@@ -7,7 +7,6 @@ use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cell::Cell; use std::cell::Cell;
use std::future::Future; use std::future::Future;
use tristr::alias::String;
const NONCE_LENGTH: usize = 12; const NONCE_LENGTH: usize = 12;
@@ -29,7 +28,7 @@ pub fn encrypt_data(data: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut combined = nonce.to_vec(); let mut combined = nonce.to_vec();
combined.extend(ciphertext); combined.extend(ciphertext);
Ok(STANDARD.encode(combined).into()) Ok(STANDARD.encode(combined))
} }
pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> { pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error>> {
@@ -47,10 +46,7 @@ pub fn decrypt_data(encrypted: &str) -> Result<String, Box<dyn std::error::Error
.decrypt(nonce.into(), ciphertext) .decrypt(nonce.into(), ciphertext)
.map_err(|e| format!("Decryption failed: {e}"))?; .map_err(|e| format!("Decryption failed: {e}"))?;
// from_utf8 exists only on the std String type; the tristr alias shadows the bare name. String::from_utf8(plaintext).map_err(|e| e.into())
std::string::String::from_utf8(plaintext)
.map_err(|e| e.into())
.map(Into::into)
} }
pub fn serialize_encrypted<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error> pub fn serialize_encrypted<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
+9
View File
@@ -14,6 +14,15 @@ mod verge;
pub(crate) use self::config::Config; pub(crate) use self::config::Config;
pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*}; pub use self::{clash::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*};
macro_rules! patch_opt_field {
($lv:expr, $rv:expr, $key:tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.clone();
}
};
}
pub(crate) use patch_opt_field;
pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;"; return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;";
} }
+2 -3
View File
@@ -18,7 +18,6 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_yaml_ng::Value; use serde_yaml_ng::Value;
use std::{collections::HashSet, net::SocketAddr, str::FromStr as _}; use std::{collections::HashSet, net::SocketAddr, str::FromStr as _};
use tristr::alias::String;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
struct MixedPortFallback { struct MixedPortFallback {
@@ -142,8 +141,8 @@ impl Config {
} }
pub(crate) fn block_startup_core(error: &anyhow::Error) { pub(crate) fn block_startup_core(error: &anyhow::Error) {
*STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string().into()); *STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
report_fallback_error(error.to_string().into()); report_fallback_error(error.to_string());
} }
pub(crate) fn startup_core_block_reason() -> Option<String> { pub(crate) fn startup_core_block_reason() -> Option<String> {
+97 -114
View File
@@ -1,3 +1,5 @@
use super::patch_opt_field;
use crate::{ use crate::{
config::profiles, config::profiles,
utils::{ utils::{
@@ -9,9 +11,9 @@ use crate::{
use anyhow::{Context as _, Result, bail}; use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::time::Duration; use std::time::Duration;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
// TODO, use other re-export // TODO, use other re-export
use reqwest_dav::re_exports::url::form_urlencoded; use reqwest_dav::re_exports::url::form_urlencoded;
use tauri::Url; use tauri::Url;
@@ -30,18 +32,24 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option<String> {
#[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)] #[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)]
#[serde(default)] #[serde(default)]
pub struct PrfItem { pub struct PrfItem {
#[specta(type = Option<std::string::String>)]
pub uid: Option<String>, pub uid: Option<String>,
/// enum value: remote | local | script | merge /// enum value: remote | local | script | merge
#[serde(rename = "type")] #[serde(rename = "type")]
#[specta(type = Option<std::string::String>)]
pub itype: Option<String>, pub itype: Option<String>,
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub file: Option<String>, pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub desc: Option<String>, pub desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -56,16 +64,20 @@ pub struct PrfItem {
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub home: Option<String>, pub home: Option<String>,
#[serde(skip)] #[serde(skip)]
#[specta(type = Option<std::string::String>)]
pub file_data: Option<String>, pub file_data: Option<String>,
} }
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)]
#[serde(default)] #[serde(default)]
pub struct PrfSelected { pub struct PrfSelected {
#[specta(type = Option<std::string::String>)]
pub name: Option<String>, pub name: Option<String>,
#[specta(type = Option<std::string::String>)]
pub now: Option<String>, pub now: Option<String>,
} }
@@ -81,6 +93,7 @@ pub struct PrfExtra {
#[serde(default)] #[serde(default)]
pub struct PrfOption { pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::string::String>)]
pub user_agent: Option<String>, pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -103,10 +116,15 @@ pub struct PrfOption {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub allow_auto_update: Option<bool>, pub allow_auto_update: Option<bool>,
#[specta(type = Option<std::string::String>)]
pub merge: Option<String>, pub merge: Option<String>,
#[specta(type = Option<std::string::String>)]
pub script: Option<String>, pub script: Option<String>,
#[specta(type = Option<std::string::String>)]
pub rules: Option<String>, pub rules: Option<String>,
#[specta(type = Option<std::string::String>)]
pub proxies: Option<String>, pub proxies: Option<String>,
#[specta(type = Option<std::string::String>)]
pub groups: Option<String>, pub groups: Option<String>,
} }
@@ -115,19 +133,18 @@ impl PrfOption {
match (one, other) { match (one, other) {
(Some(a_ref), Some(b_ref)) => { (Some(a_ref), Some(b_ref)) => {
let mut result = a_ref.clone(); let mut result = a_ref.clone();
result.user_agent = b_ref.user_agent.clone().or(result.user_agent); patch_opt_field!(result, b_ref, user_agent);
result.with_proxy = b_ref.with_proxy.or(result.with_proxy); patch_opt_field!(result, b_ref, with_proxy);
result.self_proxy = b_ref.self_proxy.or(result.self_proxy); patch_opt_field!(result, b_ref, self_proxy);
result.danger_accept_invalid_certs = patch_opt_field!(result, b_ref, danger_accept_invalid_certs);
b_ref.danger_accept_invalid_certs.or(result.danger_accept_invalid_certs); patch_opt_field!(result, b_ref, allow_auto_update);
result.allow_auto_update = b_ref.allow_auto_update.or(result.allow_auto_update); patch_opt_field!(result, b_ref, update_interval);
result.update_interval = b_ref.update_interval.or(result.update_interval); patch_opt_field!(result, b_ref, merge);
result.merge = b_ref.merge.clone().or(result.merge); patch_opt_field!(result, b_ref, script);
result.script = b_ref.script.clone().or(result.script); patch_opt_field!(result, b_ref, rules);
result.rules = b_ref.rules.clone().or(result.rules); patch_opt_field!(result, b_ref, proxies);
result.proxies = b_ref.proxies.clone().or(result.proxies); patch_opt_field!(result, b_ref, groups);
result.groups = b_ref.groups.clone().or(result.groups); patch_opt_field!(result, b_ref, timeout_seconds);
result.timeout_seconds = b_ref.timeout_seconds.or(result.timeout_seconds);
Some(result) Some(result)
} }
(Some(a_ref), None) => Some(a_ref.clone()), (Some(a_ref), None) => Some(a_ref.clone()),
@@ -165,13 +182,45 @@ impl PrfItem {
} }
} }
async fn append_companion_item(item: &mut Self) -> Result<String> {
profiles::profiles_append_item_safe(item).await?;
item.uid
.clone()
.ok_or_else(|| anyhow::anyhow!("appended companion item has no uid"))
}
async fn ensure_companion_items(
merge: &mut Option<String>,
script: &mut Option<String>,
rules: &mut Option<String>,
proxies: &mut Option<String>,
groups: &mut Option<String>,
) -> Result<()> {
if merge.is_none() {
*merge = Some(Self::append_companion_item(&mut Self::from_merge(None)).await?);
}
if script.is_none() {
*script = Some(Self::append_companion_item(&mut Self::from_script(None)).await?);
}
if rules.is_none() {
*rules = Some(Self::append_companion_item(&mut Self::from_rules()).await?);
}
if proxies.is_none() {
*proxies = Some(Self::append_companion_item(&mut Self::from_proxies()).await?);
}
if groups.is_none() {
*groups = Some(Self::append_companion_item(&mut Self::from_groups()).await?);
}
Ok(())
}
async fn from_local( async fn from_local(
name: String, name: String,
desc: String, desc: String,
file_data: Option<String>, file_data: Option<String>,
option: Option<&PrfOption>, option: Option<&PrfOption>,
) -> Result<Self> { ) -> Result<Self> {
let uid = help::get_uid("L"); let uid = help::get_uid("L").into();
let file = format!("{uid}.yaml").into(); let file = format!("{uid}.yaml").into();
let opt_ref = option.as_ref(); let opt_ref = option.as_ref();
let update_interval = opt_ref.and_then(|o| o.update_interval); let update_interval = opt_ref.and_then(|o| o.update_interval);
@@ -181,31 +230,7 @@ impl PrfItem {
let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut proxies = opt_ref.and_then(|o| o.proxies.clone());
let mut groups = opt_ref.and_then(|o| o.groups.clone()); let mut groups = opt_ref.and_then(|o| o.groups.clone());
if merge.is_none() { Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self { Ok(Self {
uid: Some(uid), uid: Some(uid),
itype: Some("local".into()), itype: Some("local".into()),
@@ -344,11 +369,11 @@ impl PrfItem {
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.and_then(normalize_profile_home_url); .and_then(normalize_profile_home_url);
let uid = help::get_uid("R"); let uid = help::get_uid("R").into();
let file = format!("{uid}.yaml").into(); let file = format!("{uid}.yaml").into();
let name = name let name = name
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.unwrap_or_else(|| filename.unwrap_or_else(|| "Remote File".into())); .unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into()));
let data = resp.text(); let data = resp.text();
let data = data.trim_start_matches('\u{feff}'); let data = data.trim_start_matches('\u{feff}');
@@ -359,31 +384,7 @@ impl PrfItem {
bail!("profile does not contain `proxies` or `proxy-providers`"); bail!("profile does not contain `proxies` or `proxy-providers`");
} }
if merge.is_none() { Self::ensure_companion_items(&mut merge, &mut script, &mut rules, &mut proxies, &mut groups).await?;
let merge_item = &mut Self::from_merge(None);
profiles::profiles_append_item_safe(merge_item).await?;
merge = merge_item.uid.clone();
}
if script.is_none() {
let script_item = &mut Self::from_script(None);
profiles::profiles_append_item_safe(script_item).await?;
script = script_item.uid.clone();
}
if rules.is_none() {
let rules_item = &mut Self::from_rules();
profiles::profiles_append_item_safe(rules_item).await?;
rules = rules_item.uid.clone();
}
if proxies.is_none() {
let proxies_item = &mut Self::from_proxies();
profiles::profiles_append_item_safe(proxies_item).await?;
proxies = proxies_item.uid.clone();
}
if groups.is_none() {
let groups_item = &mut Self::from_groups();
profiles::profiles_append_item_safe(groups_item).await?;
groups = groups_item.uid.clone();
}
Ok(Self { Ok(Self {
uid: Some(uid), uid: Some(uid),
@@ -414,7 +415,7 @@ impl PrfItem {
let (id, template) = if let Some(uid) = uid { let (id, template) = if let Some(uid) = uid {
(uid, tmpl::ITEM_MERGE.into()) (uid, tmpl::ITEM_MERGE.into())
} else { } else {
(help::get_uid("m"), tmpl::ITEM_MERGE_EMPTY.into()) (help::get_uid("m").into(), tmpl::ITEM_MERGE_EMPTY.into())
}; };
let file = format!("{id}.yaml").into(); let file = format!("{id}.yaml").into();
@@ -429,7 +430,11 @@ impl PrfItem {
} }
pub(super) fn from_script(uid: Option<String>) -> Self { pub(super) fn from_script(uid: Option<String>) -> Self {
let id = if let Some(uid) = uid { uid } else { help::get_uid("s") }; let id = if let Some(uid) = uid {
uid
} else {
help::get_uid("s").into()
};
let file = format!("{id}.js").into(); // js ext let file = format!("{id}.js").into(); // js ext
Self { Self {
uid: Some(id), uid: Some(id),
@@ -441,46 +446,30 @@ impl PrfItem {
} }
} }
fn from_rules() -> Self { fn new_seq_item(prefix: &str, itype: &str, template: &str) -> Self {
let uid = help::get_uid("r"); let uid = help::get_uid(prefix).into();
let file = format!("{uid}.yaml").into(); // yaml ext let file = format!("{uid}.yaml").into(); // yaml ext
Self { Self {
uid: Some(uid), uid: Some(uid),
itype: Some("rules".into()), itype: Some(itype.into()),
file: Some(file), file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize), updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_RULES.into()), file_data: Some(template.into()),
..Default::default() ..Default::default()
} }
} }
fn from_rules() -> Self {
Self::new_seq_item("r", "rules", tmpl::ITEM_RULES)
}
fn from_proxies() -> Self { fn from_proxies() -> Self {
let uid = help::get_uid("p"); Self::new_seq_item("p", "proxies", tmpl::ITEM_PROXIES)
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("proxies".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_PROXIES.into()),
..Default::default()
}
} }
fn from_groups() -> Self { fn from_groups() -> Self {
let uid = help::get_uid("g"); Self::new_seq_item("g", "groups", tmpl::ITEM_GROUPS)
let file = format!("{uid}.yaml").into(); // yaml ext
Self {
uid: Some(uid),
itype: Some("groups".into()),
file: Some(file),
updated: Some(chrono::Local::now().timestamp() as usize),
file_data: Some(tmpl::ITEM_GROUPS.into()),
..Default::default()
}
} }
pub(crate) async fn read_file(&self) -> Result<String> { pub(crate) async fn read_file(&self) -> Result<String> {
@@ -496,26 +485,20 @@ impl PrfItem {
} }
} }
macro_rules! current_field {
($name:ident, $field:ident) => {
pub(crate) fn $name(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.$field.as_ref())
}
};
}
impl PrfItem { impl PrfItem {
pub(crate) fn current_merge(&self) -> Option<&String> { current_field!(current_merge, merge);
self.option.as_ref().and_then(|o| o.merge.as_ref()) current_field!(current_script, script);
} current_field!(current_rules, rules);
current_field!(current_proxies, proxies);
pub(crate) fn current_script(&self) -> Option<&String> { current_field!(current_groups, groups);
self.option.as_ref().and_then(|o| o.script.as_ref())
}
pub(crate) fn current_rules(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.rules.as_ref())
}
pub(crate) fn current_proxies(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.proxies.as_ref())
}
pub(crate) fn current_groups(&self) -> Option<&String> {
self.option.as_ref().and_then(|o| o.groups.as_ref())
}
} }
fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool { fn allow_auto_update_enabled(option: Option<&PrfOption>) -> bool {
+43 -64
View File
@@ -1,5 +1,5 @@
use super::{ use super::{
PrfOption, PrfOption, patch_opt_field,
prfitem::{PrfItem, PrfSelected, normalize_profile_home_url}, prfitem::{PrfItem, PrfSelected, normalize_profile_home_url},
}; };
use crate::core::notify::{Refresh, announce}; use crate::core::notify::{Refresh, announce};
@@ -17,6 +17,7 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@@ -29,7 +30,6 @@ use std::{
}; };
use tauri_plugin_mihomo::models::{Proxies, ProxyType}; use tauri_plugin_mihomo::models::{Proxies, ProxyType};
use tokio::{fs, task::JoinHandle}; use tokio::{fs, task::JoinHandle};
use tristr::alias::String;
pub(crate) static PROFILE_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub(crate) static PROFILE_WRITE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -51,6 +51,7 @@ const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
#[serde(default)] #[serde(default)]
pub struct IProfiles { pub struct IProfiles {
#[specta(type = Option<std::string::String>)]
pub current: Option<String>, pub current: Option<String>,
pub items: Option<Vec<PrfItem>>, pub items: Option<Vec<PrfItem>>,
@@ -85,14 +86,6 @@ impl ProfileDeletePlan {
} }
} }
macro_rules! patch {
($lv: expr, $rv: expr, $key: tt) => {
if ($rv.$key).is_some() {
$lv.$key = $rv.$key.to_owned();
}
};
}
impl IProfiles { impl IProfiles {
fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> { fn take_item_file_by_uid(items: &mut Vec<PrfItem>, target_uid: Option<&str>) -> Option<String> {
let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?; let index = items.iter().position(|item| item.uid.as_deref() == target_uid)?;
@@ -114,7 +107,7 @@ impl IProfiles {
let mut home_changed = false; let mut home_changed = false;
for item in items.iter_mut() { for item in items.iter_mut() {
if item.uid.is_none() { if item.uid.is_none() {
item.uid = Some(help::get_uid("d")); item.uid = Some(help::get_uid("d").into());
} }
if item.itype.as_deref() == Some("remote") { if item.itype.as_deref() == Some("remote") {
item.option item.option
@@ -243,15 +236,15 @@ impl IProfiles {
for each in items.iter_mut() { for each in items.iter_mut() {
if each.uid.as_deref() == Some(uid) { if each.uid.as_deref() == Some(uid) {
patch!(each, item, itype); patch_opt_field!(each, item, itype);
patch!(each, item, name); patch_opt_field!(each, item, name);
patch!(each, item, desc); patch_opt_field!(each, item, desc);
patch!(each, item, file); patch_opt_field!(each, item, file);
patch!(each, item, url); patch_opt_field!(each, item, url);
patch!(each, item, selected); patch_opt_field!(each, item, selected);
patch!(each, item, extra); patch_opt_field!(each, item, extra);
patch!(each, item, updated); patch_opt_field!(each, item, updated);
patch!(each, item, option); patch_opt_field!(each, item, option);
self.items = Some(items); self.items = Some(items);
return self.save_file().await; return self.save_file().await;
@@ -419,6 +412,21 @@ pub(crate) async fn profiles_append_item_with_filedata_safe(item: &PrfItem, file
profiles_append_item_safe(item).await profiles_append_item_safe(item).await
} }
/// Applies `edit` to the committed profiles; commits only when the edit succeeds.
async fn with_profiles_edit<F, Fut>(edit: F) -> Result<()>
where
F: FnOnce(IProfiles) -> Fut + Send,
Fut: std::future::Future<Output = Result<IProfiles>> + Send,
{
Config::profiles()
.await
.with_data_modify(|profiles| async move {
let profiles = edit(profiles).await?;
Ok((profiles, ()))
})
.await
}
pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> { pub(crate) async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> {
let profiles = Config::profiles().await; let profiles = Config::profiles().await;
profiles_append_item_to_safe(&profiles, item).await profiles_append_item_to_safe(&profiles, item).await
@@ -434,43 +442,19 @@ pub(super) async fn profiles_append_item_to_safe(profiles: &Draft<IProfiles>, it
} }
pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> { pub(crate) async fn profiles_patch_item_safe(index: &str, item: &PrfItem) -> Result<()> {
Config::profiles() with_profiles_edit(|mut profiles| async move { profiles.patch_item(index, item).await.map(|_| profiles) }).await
.await
.with_data_modify(|mut profiles| async move {
profiles.patch_item(index, item).await?;
Ok((profiles, ()))
})
.await
} }
pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> { pub(crate) async fn profiles_reorder_safe(active_id: &str, over_id: &str) -> Result<()> {
Config::profiles() with_profiles_edit(|mut profiles| async move { profiles.reorder(active_id, over_id).await.map(|_| profiles) }).await
.await
.with_data_modify(|mut profiles| async move {
profiles.reorder(active_id, over_id).await?;
Ok((profiles, ()))
})
.await
} }
pub(crate) async fn profiles_save_file_safe() -> Result<()> { pub(crate) async fn profiles_save_file_safe() -> Result<()> {
Config::profiles() with_profiles_edit(|profiles| async move { profiles.save_file().await.map(|_| profiles) }).await
.await
.with_data_modify(|profiles| async move {
profiles.save_file().await?;
Ok((profiles, ()))
})
.await
} }
pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> { pub(crate) async fn profiles_update_item_safe(index: &str, item: &mut PrfItem) -> Result<()> {
Config::profiles() with_profiles_edit(|mut profiles| async move { profiles.update_item(index, item).await.map(|_| profiles) }).await
.await
.with_data_modify(|mut profiles| async move {
profiles.update_item(index, item).await?;
Ok((profiles, ()))
})
.await
} }
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -712,19 +696,21 @@ where
Ok(()) Ok(())
} }
fn current_item_mut(profiles: &mut IProfiles) -> Option<&mut PrfItem> {
let current = profiles.current.clone()?;
profiles
.items
.as_mut()?
.iter_mut()
.find(|item| item.uid.as_ref() == Some(&current))
}
/// Records a backend-made selection so the next core start restores it. /// Records a backend-made selection so the next core start restores it.
pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name); let group_name = String::from(group_name);
let node = String::from(node); let node = String::from(node);
persist_selected_update(true, move |mut profiles| async move { persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else { let Some(item) = current_item_mut(&mut profiles) else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false)); return Ok((profiles, false));
}; };
@@ -759,14 +745,7 @@ fn remove_selected_node(selected: &mut Vec<PrfSelected>, group_name: &str) -> bo
pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> { pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> {
persist_selected_update(true, move |mut profiles| async move { persist_selected_update(true, move |mut profiles| async move {
let Some(current) = profiles.current.clone() else { let Some(item) = current_item_mut(&mut profiles) else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false)); return Ok((profiles, false));
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use tristr::alias::String;
use crate::enhance::field::use_keys; use crate::enhance::field::use_keys;
+77 -85
View File
@@ -1,6 +1,6 @@
use crate::config::Config; use crate::config::Config;
use crate::{ use crate::{
config::{DEFAULT_PAC, deserialize_encrypted, serialize_encrypted}, config::{DEFAULT_PAC, deserialize_encrypted, patch_opt_field, serialize_encrypted},
constants::network, constants::network,
utils::{dirs, help}, utils::{dirs, help},
}; };
@@ -8,7 +8,7 @@ use anyhow::Result;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use log::LevelFilter; use log::LevelFilter;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tristr::alias::String; use smartstring::alias::String;
/// ### `verge.yaml` schema /// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] #[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)]
@@ -39,7 +39,7 @@ pub struct IVerge {
pub traffic_graph: Option<bool>, pub traffic_graph: Option<bool>,
/// show memory info (only for Clash Meta) /// show memory info (only for Mihomo)
pub enable_memory_usage: Option<bool>, pub enable_memory_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -432,98 +432,90 @@ impl IVerge {
#[allow(clippy::cognitive_complexity)] #[allow(clippy::cognitive_complexity)]
pub fn patch_config(&mut self, patch: &Self) { pub fn patch_config(&mut self, patch: &Self) {
macro_rules! patch { patch_opt_field!(self, patch, app_log_level);
($key: tt) => { patch_opt_field!(self, patch, app_log_max_size);
if patch.$key.is_some() { patch_opt_field!(self, patch, app_log_max_count);
self.$key = patch.$key.clone();
}
};
}
patch!(app_log_level); patch_opt_field!(self, patch, language);
patch!(app_log_max_size); patch_opt_field!(self, patch, theme_mode);
patch!(app_log_max_count); patch_opt_field!(self, patch, tray_event);
patch_opt_field!(self, patch, env_type);
patch!(language); patch_opt_field!(self, patch, start_page);
patch!(theme_mode); patch_opt_field!(self, patch, startup_script);
patch!(tray_event); patch_opt_field!(self, patch, traffic_graph);
patch!(env_type); patch_opt_field!(self, patch, enable_memory_usage);
patch!(start_page); patch_opt_field!(self, patch, enable_group_icon);
patch!(startup_script); patch_opt_field!(self, patch, pause_render_traffic_stats_on_blur);
patch!(traffic_graph);
patch!(enable_memory_usage);
patch!(enable_group_icon);
patch!(pause_render_traffic_stats_on_blur);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
patch!(tray_icon); patch_opt_field!(self, patch, tray_icon);
patch!(menu_icon); patch_opt_field!(self, patch, menu_icon);
patch!(menu_order); patch_opt_field!(self, patch, menu_order);
patch!(proxy_group_tools_position); patch_opt_field!(self, patch, proxy_group_tools_position);
patch!(notice_position); patch_opt_field!(self, patch, notice_position);
patch!(collapse_navbar); patch_opt_field!(self, patch, collapse_navbar);
patch!(common_tray_icon); patch_opt_field!(self, patch, common_tray_icon);
patch!(sysproxy_tray_icon); patch_opt_field!(self, patch, sysproxy_tray_icon);
patch!(tun_tray_icon); patch_opt_field!(self, patch, tun_tray_icon);
patch!(enable_tun_mode); patch_opt_field!(self, patch, enable_tun_mode);
patch!(enable_auto_launch); patch_opt_field!(self, patch, enable_auto_launch);
patch!(enable_silent_start); patch_opt_field!(self, patch, enable_silent_start);
patch!(enable_hover_jump_navigator); patch_opt_field!(self, patch, enable_hover_jump_navigator);
patch!(hover_jump_navigator_delay); patch_opt_field!(self, patch, hover_jump_navigator_delay);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
patch!(verge_redir_port); patch_opt_field!(self, patch, verge_redir_port);
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
patch!(verge_redir_enabled); patch_opt_field!(self, patch, verge_redir_enabled);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
patch!(verge_tproxy_port); patch_opt_field!(self, patch, verge_tproxy_port);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
patch!(verge_tproxy_enabled); patch_opt_field!(self, patch, verge_tproxy_enabled);
patch!(verge_mixed_port); patch_opt_field!(self, patch, verge_mixed_port);
patch!(verge_socks_port); patch_opt_field!(self, patch, verge_socks_port);
patch!(verge_socks_enabled); patch_opt_field!(self, patch, verge_socks_enabled);
patch!(verge_port); patch_opt_field!(self, patch, verge_port);
patch!(verge_http_enabled); patch_opt_field!(self, patch, verge_http_enabled);
patch!(enable_system_proxy); patch_opt_field!(self, patch, enable_system_proxy);
patch!(enable_proxy_guard); patch_opt_field!(self, patch, enable_proxy_guard);
patch!(enable_bypass_check); patch_opt_field!(self, patch, enable_bypass_check);
patch!(use_default_bypass); patch_opt_field!(self, patch, use_default_bypass);
patch!(system_proxy_bypass); patch_opt_field!(self, patch, system_proxy_bypass);
patch!(proxy_guard_duration); patch_opt_field!(self, patch, proxy_guard_duration);
patch!(proxy_auto_config); patch_opt_field!(self, patch, proxy_auto_config);
patch!(pac_file_content); patch_opt_field!(self, patch, pac_file_content);
patch!(proxy_host); patch_opt_field!(self, patch, proxy_host);
patch!(theme_setting); patch_opt_field!(self, patch, theme_setting);
patch!(web_ui_list); patch_opt_field!(self, patch, web_ui_list);
patch!(clash_core); patch_opt_field!(self, patch, clash_core);
patch!(hotkeys); patch_opt_field!(self, patch, hotkeys);
patch!(enable_global_hotkey); patch_opt_field!(self, patch, enable_global_hotkey);
patch!(auto_close_connection); patch_opt_field!(self, patch, auto_close_connection);
patch!(auto_check_update); patch_opt_field!(self, patch, auto_check_update);
patch!(default_latency_test); patch_opt_field!(self, patch, default_latency_test);
patch!(default_latency_timeout); patch_opt_field!(self, patch, default_latency_timeout);
patch!(enable_auto_delay_detection); patch_opt_field!(self, patch, enable_auto_delay_detection);
patch!(auto_delay_detection_interval_minutes); patch_opt_field!(self, patch, auto_delay_detection_interval_minutes);
patch!(enable_builtin_enhanced); patch_opt_field!(self, patch, enable_builtin_enhanced);
patch!(proxy_layout_column); patch_opt_field!(self, patch, proxy_layout_column);
patch!(test_list); patch_opt_field!(self, patch, test_list);
patch!(auto_log_clean); patch_opt_field!(self, patch, auto_log_clean);
patch!(enable_auto_backup_schedule); patch_opt_field!(self, patch, enable_auto_backup_schedule);
patch!(auto_backup_interval_hours); patch_opt_field!(self, patch, auto_backup_interval_hours);
patch!(auto_backup_on_change); patch_opt_field!(self, patch, auto_backup_on_change);
patch!(webdav_url); patch_opt_field!(self, patch, webdav_url);
patch!(webdav_username); patch_opt_field!(self, patch, webdav_username);
patch!(webdav_password); patch_opt_field!(self, patch, webdav_password);
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
patch!(enable_tray_speed); patch_opt_field!(self, patch, enable_tray_speed);
patch!(tray_proxy_groups_display_mode); patch_opt_field!(self, patch, tray_proxy_groups_display_mode);
patch!(tray_inline_outbound_modes); patch_opt_field!(self, patch, tray_inline_outbound_modes);
patch!(enable_auto_light_weight_mode); patch_opt_field!(self, patch, enable_auto_light_weight_mode);
patch!(auto_light_weight_minutes); patch_opt_field!(self, patch, auto_light_weight_minutes);
patch!(enable_dns_settings); patch_opt_field!(self, patch, enable_dns_settings);
patch!(home_cards); patch_opt_field!(self, patch, home_cards);
patch!(enable_external_controller); patch_opt_field!(self, patch, enable_external_controller);
} }
pub fn get_log_level(&self) -> LevelFilter { pub fn get_log_level(&self) -> LevelFilter {
+7
View File
@@ -14,6 +14,13 @@ pub mod network {
} }
} }
pub mod sysproxy {
/// Single source of the macOS default bypass list; both proxy backends compose from it.
#[cfg(target_os = "macos")]
pub const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
}
pub mod timing { pub mod timing {
use super::Duration; use super::Duration;
+12 -19
View File
@@ -7,11 +7,10 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use chrono::Local; use chrono::Local;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use std::{ use std::{
path::PathBuf, path::PathBuf,
sync::{ sync::{
Arc, OnceLock, OnceLock,
atomic::{AtomicBool, AtomicI64, Ordering}, atomic::{AtomicBool, AtomicI64, Ordering},
}, },
time::{Duration, UNIX_EPOCH}, time::{Duration, UNIX_EPOCH},
@@ -20,7 +19,6 @@ use tokio::{
fs, fs,
sync::{Mutex, watch}, sync::{Mutex, watch},
}; };
use tristr::alias::String;
const DEFAULT_INTERVAL_HOURS: u64 = 24; const DEFAULT_INTERVAL_HOURS: u64 = 24;
const MIN_INTERVAL_HOURS: u64 = 1; const MIN_INTERVAL_HOURS: u64 = 1;
@@ -83,8 +81,9 @@ impl Default for AutoBackupSettings {
} }
pub struct AutoBackupManager { pub struct AutoBackupManager {
settings: Arc<RwLock<AutoBackupSettings>>,
settings_tx: watch::Sender<AutoBackupSettings>, settings_tx: watch::Sender<AutoBackupSettings>,
// The watch channel is the single settings store; no duplicate copies.
settings_rx: watch::Receiver<AutoBackupSettings>,
runner_started: AtomicBool, runner_started: AtomicBool,
exec_lock: Mutex<()>, exec_lock: Mutex<()>,
last_backup: AtomicI64, last_backup: AtomicI64,
@@ -94,10 +93,10 @@ impl AutoBackupManager {
pub fn global() -> &'static Self { pub fn global() -> &'static Self {
static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new(); static INSTANCE: OnceLock<AutoBackupManager> = OnceLock::new();
INSTANCE.get_or_init(|| { INSTANCE.get_or_init(|| {
let (tx, _rx) = watch::channel(AutoBackupSettings::default()); let (settings_tx, settings_rx) = watch::channel(AutoBackupSettings::default());
Self { Self {
settings: Arc::new(RwLock::new(AutoBackupSettings::default())), settings_tx,
settings_tx: tx, settings_rx,
runner_started: AtomicBool::new(false), runner_started: AtomicBool::new(false),
exec_lock: Mutex::new(()), exec_lock: Mutex::new(()),
last_backup: AtomicI64::new(0), last_backup: AtomicI64::new(0),
@@ -107,20 +106,14 @@ impl AutoBackupManager {
pub async fn init(&self) -> Result<()> { pub async fn init(&self) -> Result<()> {
let settings = Self::load_settings().await; let settings = Self::load_settings().await;
{ let _ = self.settings_tx.send_replace(settings);
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings); self.maybe_start_runner(settings);
Ok(()) Ok(())
} }
pub async fn refresh_settings(&self) -> Result<()> { pub async fn refresh_settings(&self) -> Result<()> {
let settings = Self::load_settings().await; let settings = Self::load_settings().await;
{ let _ = self.settings_tx.send_replace(settings);
*self.settings.write() = settings;
}
let _ = self.settings_tx.send(settings);
self.maybe_start_runner(settings); self.maybe_start_runner(settings);
Ok(()) Ok(())
} }
@@ -194,7 +187,7 @@ impl AutoBackupManager {
} }
async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> { async fn execute_trigger(&self, trigger: AutoBackupTrigger) -> Result<()> {
let snapshot = *self.settings.read(); let snapshot = *self.settings_rx.borrow();
if trigger.is_schedule() && !snapshot.schedule_enabled { if trigger.is_schedule() && !snapshot.schedule_enabled {
return Ok(()); return Ok(());
@@ -212,7 +205,7 @@ impl AutoBackupManager {
return Ok(()); return Ok(());
} }
let file_name = create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug())).await?; let file_name = create_local_backup_with_namer(|name| append_auto_suffix(name, trigger.slug()).into()).await?;
self.last_backup.store(Local::now().timestamp(), Ordering::Release); self.last_backup.store(Local::now().timestamp(), Ordering::Release);
if let Err(err) = cleanup_auto_backups().await { if let Err(err) = cleanup_auto_backups().await {
@@ -240,8 +233,8 @@ impl AutoBackupManager {
fn append_auto_suffix(file_name: &str, slug: &str) -> String { fn append_auto_suffix(file_name: &str, slug: &str) -> String {
match file_name.rsplit_once('.') { match file_name.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}").into(), Some((stem, ext)) => format!("{stem}{AUTO_MARKER}{slug}.{ext}"),
None => format!("{file_name}{AUTO_MARKER}{slug}").into(), None => format!("{file_name}{AUTO_MARKER}{slug}"),
} }
} }
+1 -1
View File
@@ -5,6 +5,7 @@ use arc_swap::{ArcSwap, ArcSwapOption};
use backon::{ConstantBuilder, Retryable as _}; use backon::{ConstantBuilder, Retryable as _};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus}; use reqwest_dav::list_cmd::{ListEntity, ListFile, ListMultiStatus};
use smartstring::alias::String;
use std::{ use std::{
collections::HashMap, collections::HashMap,
env::{consts::OS, temp_dir}, env::{consts::OS, temp_dir},
@@ -14,7 +15,6 @@ use std::{
time::Duration, time::Duration,
}; };
use tokio::{fs, time::timeout}; use tokio::{fs, time::timeout};
use tristr::alias::String;
use zip::write::SimpleFileOptions; use zip::write::SimpleFileOptions;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
+1 -10
View File
@@ -7,6 +7,7 @@ use crate::{config::Config, core::handle, core::lightweight::entry_lightweight_m
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fmt, fmt,
@@ -17,7 +18,6 @@ use std::{
}, },
}; };
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt as _, ShortcutState}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt as _, ShortcutState};
use tristr::alias::String;
/// Enum representing all available hotkey functions /// Enum representing all available hotkey functions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -466,12 +466,3 @@ impl Hotkey {
(del_list, add_list) (del_list, add_list)
} }
} }
impl Drop for Hotkey {
fn drop(&mut self) {
let app_handle = handle::Handle::app_handle();
if let Err(e) = app_handle.global_shortcut().unregister_all() {
logging!(error, Type::Hotkey, "Error unregistering all hotkeys: {:?}", e);
}
}
}
+53 -42
View File
@@ -40,8 +40,47 @@ impl LightweightState {
static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8); static LIGHTWEIGHT_STATE: AtomicU8 = AtomicU8::new(LightweightState::Normal as u8);
static WINDOW_CLOSE_HANDLER_ID: AtomicU32 = AtomicU32::new(0); struct WindowListener {
static WEBVIEW_FOCUS_HANDLER_ID: AtomicU32 = AtomicU32::new(0); handler_id: AtomicU32,
event: &'static str,
label: &'static str,
}
impl WindowListener {
fn setup(&self, on_event: impl Fn() + Send + 'static) {
if let Some(window) = WindowManager::get_main_window() {
let previous_handler_id = self.handler_id.swap(0, Ordering::AcqRel);
if previous_handler_id != 0 {
window.unlisten(previous_handler_id);
logging!(debug, Type::Lightweight, "覆盖旧的{}监听", self.label);
}
let handler_id = window.listen(self.event, move |_event| on_event());
self.handler_id.store(handler_id, Ordering::Release);
}
}
fn cancel(&self) {
let id = self.handler_id.swap(0, Ordering::AcqRel);
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了{}监听", self.label);
}
}
}
static WINDOW_CLOSE_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://close-requested",
label: "窗口关闭",
};
static WEBVIEW_FOCUS_LISTENER: WindowListener = WindowListener {
handler_id: AtomicU32::new(0),
event: "tauri://focus",
label: "窗口焦点",
};
static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None); static CANCEL_TX: Mutex<Option<oneshot::Sender<()>>> = Mutex::new(None);
@@ -154,55 +193,27 @@ pub async fn add_light_weight_timer() {
} }
fn setup_window_close_listener() { fn setup_window_close_listener() {
if let Some(window) = WindowManager::get_main_window() { WINDOW_CLOSE_LISTENER.setup(|| {
let previous_handler_id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); std::mem::drop(AsyncHandler::spawn(|| async {
if previous_handler_id != 0 { setup_light_weight_timer().await;
window.unlisten(previous_handler_id); }));
logging!(debug, Type::Lightweight, "覆盖旧的窗口关闭监听"); logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
} });
let handler_id = window.listen("tauri://close-requested", move |_event| {
std::mem::drop(AsyncHandler::spawn(|| async {
setup_light_weight_timer().await;
}));
logging!(info, Type::Lightweight, "监听到关闭请求,开始轻量模式计时");
});
WINDOW_CLOSE_HANDLER_ID.store(handler_id, Ordering::Release);
}
} }
fn cancel_window_close_listener() { fn cancel_window_close_listener() {
let id = WINDOW_CLOSE_HANDLER_ID.swap(0, Ordering::AcqRel); WINDOW_CLOSE_LISTENER.cancel();
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口关闭监听");
}
} }
fn setup_webview_focus_listener() { fn setup_webview_focus_listener() {
if let Some(window) = WindowManager::get_main_window() { WEBVIEW_FOCUS_LISTENER.setup(|| {
let previous_handler_id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); cancel_light_weight_timer();
if previous_handler_id != 0 { logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
window.unlisten(previous_handler_id); });
logging!(debug, Type::Lightweight, "覆盖旧的窗口焦点监听");
}
let handler_id = window.listen("tauri://focus", move |_event| {
cancel_light_weight_timer();
logging!(debug, Type::Lightweight, "监听到窗口获得焦点,取消轻量模式计时");
});
WEBVIEW_FOCUS_HANDLER_ID.store(handler_id, Ordering::Release);
}
} }
fn cancel_webview_focus_listener() { fn cancel_webview_focus_listener() {
let id = WEBVIEW_FOCUS_HANDLER_ID.swap(0, Ordering::AcqRel); WEBVIEW_FOCUS_LISTENER.cancel();
if id != 0 {
if let Some(window) = WindowManager::get_main_window() {
window.unlisten(id);
}
logging!(debug, Type::Lightweight, "取消了窗口焦点监听");
}
} }
async fn setup_light_weight_timer() { async fn setup_light_weight_timer() {
+10 -13
View File
@@ -10,7 +10,6 @@ use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
str::FromStr as _, str::FromStr as _,
}; };
use tristr::alias::String;
/// The config key of the Mixed Port, named once so callers stop spelling it out. /// The config key of the Mixed Port, named once so callers stop spelling it out.
pub(crate) const MIXED_PORT_KEY: &str = "mixed-port"; pub(crate) const MIXED_PORT_KEY: &str = "mixed-port";
@@ -164,7 +163,7 @@ pub(crate) fn probe_listener(request: &ListenerProbe) -> ListenerProbeOutcome {
Ok(address) => address, Ok(address) => address,
Err(error) => { Err(error) => {
return ListenerProbeOutcome::Invalid { return ListenerProbeOutcome::Invalid {
message: error.to_string().into(), message: error.to_string(),
}; };
} }
}; };
@@ -202,7 +201,7 @@ pub(crate) fn probe_proxy_port_change(
Ok(claims) => claims, Ok(claims) => claims,
Err(error) => { Err(error) => {
return ListenerProbeOutcome::Invalid { return ListenerProbeOutcome::Invalid {
message: format!("current proxy listener configuration is invalid: {error:#}").into(), message: format!("current proxy listener configuration is invalid: {error:#}"),
}; };
} }
} }
@@ -213,7 +212,7 @@ pub(crate) fn probe_proxy_port_change(
Ok(claims) => claims, Ok(claims) => claims,
Err(error) => { Err(error) => {
return ListenerProbeOutcome::Invalid { return ListenerProbeOutcome::Invalid {
message: format!("candidate proxy listener configuration is invalid: {error:#}").into(), message: format!("candidate proxy listener configuration is invalid: {error:#}"),
}; };
} }
}; };
@@ -402,8 +401,7 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
claim.socket_addr(), claim.socket_addr(),
transport_name(claim.transport), transport_name(claim.transport),
error error
) ),
.into(),
}; };
} }
Err(error) => { Err(error) => {
@@ -414,8 +412,7 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
claim.socket_addr(), claim.socket_addr(),
transport_name(claim.transport), transport_name(claim.transport),
error error
) ),
.into(),
}; };
} }
} }
@@ -742,7 +739,7 @@ mod tests {
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
assert_eq!( assert_eq!(
probe_listener(&ListenerProbe { probe_listener(&ListenerProbe {
address: format!("0.0.0.0:{port}").into(), address: format!("0.0.0.0:{port}"),
transports: vec![ListenerTransport::Tcp], transports: vec![ListenerTransport::Tcp],
}), }),
ListenerProbeOutcome::Conflict { ListenerProbeOutcome::Conflict {
@@ -758,7 +755,7 @@ mod tests {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe { let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}").into(), address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Tcp], transports: vec![ListenerTransport::Tcp],
}); });
assert_eq!( assert_eq!(
@@ -776,7 +773,7 @@ mod tests {
let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?; let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let port = listener.local_addr()?.port(); let port = listener.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe { let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}").into(), address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Tcp], transports: vec![ListenerTransport::Tcp],
}); });
assert_eq!( assert_eq!(
@@ -812,7 +809,7 @@ mod tests {
assert_eq!( assert_eq!(
probe_listener(&ListenerProbe { probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}").into(), address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Tcp], transports: vec![ListenerTransport::Tcp],
}), }),
ListenerProbeOutcome::Available ListenerProbeOutcome::Available
@@ -825,7 +822,7 @@ mod tests {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = socket.local_addr()?.port(); let port = socket.local_addr()?.port();
let outcome = probe_listener(&ListenerProbe { let outcome = probe_listener(&ListenerProbe {
address: format!("127.0.0.1:{port}").into(), address: format!("127.0.0.1:{port}"),
transports: vec![ListenerTransport::Udp], transports: vec![ListenerTransport::Udp],
}); });
assert_eq!( assert_eq!(
+2 -2
View File
@@ -15,6 +15,7 @@ use clash_verge_draft::DraftTransaction;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::StageRuntimeOutcome; use clash_verge_service_ipc::StageRuntimeOutcome;
use scopeguard::defer; use scopeguard::defer;
use smartstring::alias::String;
use std::{ use std::{
collections::HashSet, collections::HashSet,
path::PathBuf, path::PathBuf,
@@ -22,7 +23,6 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tauri_plugin_mihomo::Error as MihomoError; use tauri_plugin_mihomo::Error as MihomoError;
use tristr::alias::String;
/// A staging result separated from the policy that acts on it. /// A staging result separated from the policy that acts on it.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -92,7 +92,7 @@ async fn ask_to_stage(path: &std::path::Path) -> StageAttempt {
match crate::core::service::stage_runtime_by_service(path).await { match crate::core::service::stage_runtime_by_service(path).await {
Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome), Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome),
Ok(StageRequest::Refused { code, message }) => { Ok(StageRequest::Refused { code, message }) => {
let message = message; let message = message.into();
if StageRequest::is_about_the_bundle(code) { if StageRequest::is_about_the_bundle(code) {
StageAttempt::RefusedTheBundle(message) StageAttempt::RefusedTheBundle(message)
} else { } else {
+12 -7
View File
@@ -7,10 +7,10 @@ use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
use anyhow::Result; use anyhow::Result;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use scopeguard::defer; use scopeguard::defer;
use smartstring::alias::String;
use std::path::Path; use std::path::Path;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
use tristr::alias::String;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admin: bool) -> bool { const fn should_wait_for_service(tun_enabled: bool, service_ready: bool, is_admin: bool) -> bool {
@@ -194,7 +194,7 @@ async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, Chec
start_service: Start, start_service: Start,
confirm_idle: Check, confirm_idle: Check,
start_sidecar: Fallback, start_sidecar: Fallback,
record_unavailable: impl FnOnce(String), record_unavailable: impl FnOnce(std::string::String),
) -> Result<()> ) -> Result<()>
where where
Start: FnOnce() -> StartFuture, Start: FnOnce() -> StartFuture,
@@ -217,7 +217,7 @@ where
// A lost start response can hide a running core; the shared check must resolve occupancy. // A lost start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await { if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}")); let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}").into()); record_unavailable(format!("{error:#}"));
return Err(error); return Err(error);
} }
logging!( logging!(
@@ -435,6 +435,10 @@ impl CoreManager {
{ {
anyhow::bail!("Sidecar did not become ready"); anyhow::bail!("Sidecar did not become ready");
} }
// A Sidecar that was already running never loaded this generation.
if matches!(*mode, RunningMode::NotRunning) {
Config::runtime().await.apply();
}
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await self.apply_proxy_after_start().await
} }
@@ -492,6 +496,7 @@ impl CoreManager {
{ {
anyhow::bail!("Sidecar did not become ready after service uninstall"); anyhow::bail!("Sidecar did not become ready after service uninstall");
} }
Config::runtime().await.apply();
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
self.apply_proxy_after_start().await self.apply_proxy_after_start().await
}, },
@@ -650,7 +655,7 @@ impl CoreManager {
|error| self.start_sidecar_after_service_failure(error), |error| self.start_sidecar_after_service_failure(error),
|reason| { |reason| {
crate::core::runstate::RUN_STATE crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason.into())); .observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
}, },
) )
.await .await
@@ -688,7 +693,7 @@ impl CoreManager {
} }
let reason = match state.health { let reason = match state.health {
ServiceHealth::Unavailable(reason) => reason, ServiceHealth::Unavailable(reason) => reason,
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".into(), ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()), _ => return Ok(()),
}; };
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available() if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available()
@@ -712,7 +717,7 @@ impl CoreManager {
if Handle::global().is_exiting() { if Handle::global().is_exiting() {
return Err(error.context("application exited before Sidecar fallback")); return Err(error.context("application exited before Sidecar fallback"));
} }
let reason: String = format!("{error:#}").into(); let reason = format!("{error:#}");
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?; RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}"); logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
let result = self.start_core_by_sidecar().await; let result = self.start_core_by_sidecar().await;
@@ -1590,7 +1595,7 @@ mod tests {
started.store(true, Ordering::SeqCst); started.store(true, Ordering::SeqCst);
Ok(()) Ok(())
}, },
|reason| store.observe(ServiceHealth::Unavailable(reason.into())), |reason| store.observe(ServiceHealth::Unavailable(reason)),
) )
.await; .await;
+5 -1
View File
@@ -5,7 +5,7 @@ mod state;
use anyhow::Result; use anyhow::Result;
use arc_swap::{ArcSwap, ArcSwapOption}; use arc_swap::{ArcSwap, ArcSwapOption};
use clash_verge_logging::{LogRing, Type, logging}; use clash_verge_logging::{LogRing, Type, logging, logging_error};
use std::{ use std::{
fmt, fmt,
sync::{ sync::{
@@ -274,6 +274,10 @@ impl CoreManager {
pub(crate) fn finish_config_update(&self) { pub(crate) fn finish_config_update(&self) {
self.config_update_in_progress.store(false, Ordering::Release); self.config_update_in_progress.store(false, Ordering::Release);
// Spawned because the caller may still hold the config write lock this waits for.
crate::process::AsyncHandler::spawn(|| async {
logging_error!(Type::Config, crate::config::Config::sync_dns_override().await);
});
} }
#[tracing::instrument(skip_all, level = "info", fields(retries = 0))] #[tracing::instrument(skip_all, level = "info", fields(retries = 0))]
+8 -11
View File
@@ -16,7 +16,6 @@ use std::num::NonZeroUsize;
use std::path::Path; use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _; use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _; use tauri_plugin_shell::ShellExt as _;
use tristr::alias::String;
pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error { pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
let detail = error.to_string(); let detail = error.to_string();
@@ -28,7 +27,7 @@ pub(super) fn explain_core_occupancy(error: anyhow::Error) -> anyhow::Error {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<String> { fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?; let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
@@ -41,7 +40,7 @@ fn sidecar_config_without_tun(yaml: &str) -> Result<String> {
tun.as_mapping_mut() tun.as_mapping_mut()
.context("invalid Sidecar TUN configuration")? .context("invalid Sidecar TUN configuration")?
.insert(Value::from("enable"), Value::Bool(false)); .insert(Value::from("enable"), Value::Bool(false));
Ok(serde_yaml_ng::to_string(&config)?.into()) Ok(serde_yaml_ng::to_string(&config)?)
} }
const SIDECAR_READINESS_ATTEMPTS: usize = 30; const SIDECAR_READINESS_ATTEMPTS: usize = 30;
@@ -154,7 +153,7 @@ impl CoreManager {
pub async fn get_clash_logs(&self) -> Result<Vec<String>> { pub async fn get_clash_logs(&self) -> Result<Vec<String>> {
match *self.get_running_mode() { match *self.get_running_mode() {
RunningMode::Service => service::get_clash_logs_by_service().await, RunningMode::Service => service::get_clash_logs_by_service().await,
RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs().into_iter().map(Into::into).collect()), RunningMode::Sidecar => Ok(CLASH_LOGGER.get_logs()),
RunningMode::NotRunning => Ok(Vec::new()), RunningMode::NotRunning => Ok(Vec::new()),
} }
} }
@@ -258,9 +257,7 @@ impl CoreManager {
match event { match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line) tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => { | tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
// from_utf8_lossy_owned exists only on the std String type; the tristr let message = String::from_utf8_lossy_owned(line);
// alias shadows the bare name. LogRing stores std Strings.
let message = std::string::String::from_utf8_lossy_owned(line);
Logger::global().writer_sidecar_log(Level::Error, &message); Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message); CLASH_LOGGER.append_log(message);
} }
@@ -268,12 +265,12 @@ impl CoreManager {
let _ = terminated.send(()); let _ = terminated.send(());
let manager = Self::global(); let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation); let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message: String = if let Some(code) = term.code { let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code).into() format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal { } else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal).into() format!("Process terminated by signal: {}", signal)
} else { } else {
"Process terminated".into() String::from("Process terminated")
}; };
Logger::global().writer_sidecar_log(Level::Info, &message); Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs(); CLASH_LOGGER.clear_logs();
+6 -3
View File
@@ -1,9 +1,12 @@
use super::NoticeStatus; use super::NoticeStatus;
use crate::{APP_HANDLE, singleton}; use crate::{APP_HANDLE, singleton};
use std::sync::atomic::{AtomicBool, Ordering}; use smartstring::alias::String;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_mihomo::{Mihomo, MihomoExt as _}; use tauri_plugin_mihomo::{Mihomo, MihomoExt as _};
use tristr::alias::String;
use super::notification::{FrontendEvent, NotificationSystem}; use super::notification::{FrontendEvent, NotificationSystem};
@@ -70,7 +73,7 @@ impl Handle {
Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid }); Self::send_event(FrontendEvent::ProfileUpdateCompleted { uid });
} }
pub fn notice(status: NoticeStatus, message: impl Into<String>) { pub fn notice(status: NoticeStatus, message: impl Into<Arc<str>>) {
Self::send_event(FrontendEvent::NoticeMessage { Self::send_event(FrontendEvent::NoticeMessage {
status, status,
message: message.into(), message: message.into(),
+13 -9
View File
@@ -3,10 +3,11 @@ use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
#[cfg(test)] #[cfg(test)]
use serde_json::json; use serde_json::json;
use smartstring::alias::String;
use std::sync::Arc;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::{collections::HashMap, future::Future}; use std::{collections::HashMap, future::Future};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow}; use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
use tristr::alias::String;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum FrontendEvent<'a> { pub enum FrontendEvent<'a> {
@@ -16,7 +17,7 @@ pub enum FrontendEvent<'a> {
RefreshProxyConfig, RefreshProxyConfig,
NoticeMessage { NoticeMessage {
status: NoticeStatus, status: NoticeStatus,
message: String, message: Arc<str>,
}, },
ProfileChanged { ProfileChanged {
current_profile_id: &'a String, current_profile_id: &'a String,
@@ -76,8 +77,10 @@ impl FailedOperation {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PendingFailure { pub struct PendingFailure {
/// Stable code used as the table key. /// Stable code used as the table key.
#[specta(type = std::string::String)]
pub code: String, pub code: String,
/// Full diagnostic context chain. /// Full diagnostic context chain.
#[specta(type = std::string::String)]
pub detail: String, pub detail: String,
pub operation: FailedOperation, pub operation: FailedOperation,
/// Monotonic identity for repeated failures under the same code. /// Monotonic identity for repeated failures under the same code.
@@ -244,6 +247,7 @@ static PENDING_FAILURES: LazyLock<FailureTable> = LazyLock::new(FailureTable::de
#[derive(Debug, Default, Clone, serde::Serialize, specta::Type)] #[derive(Debug, Default, Clone, serde::Serialize, specta::Type)]
pub struct SidecarFailureSnapshot { pub struct SidecarFailureSnapshot {
pub revision: u64, pub revision: u64,
#[specta(type = Option<std::string::String>)]
pub detail: Option<String>, pub detail: Option<String>,
} }
@@ -387,7 +391,7 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str,
( (
FrontendEvent::NoticeMessage { FrontendEvent::NoticeMessage {
status: NoticeStatus::Info, status: NoticeStatus::Info,
message: String::from("ok"), message: Arc::from("ok"),
}, },
"verge://notice-message", "verge://notice-message",
json!(["info", "ok"]), json!(["info", "ok"]),
@@ -452,16 +456,16 @@ frontend_events! {
refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => (); refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => ();
refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => (); refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => ();
refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => (); refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => ();
notice: (NoticeStatus, String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.clone()); notice: (NoticeStatus, std::string::String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.to_string());
profile_changed: String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.clone(); profile_changed: std::string::String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.to_string();
timer_updated: String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.clone(); timer_updated: std::string::String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.to_string();
profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.clone() }; profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.clone() }; profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.to_string() };
run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state; run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state;
pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => (); pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => ();
} }
#[derive(serde::Serialize, specta::Type)] #[derive(serde::Serialize, specta::Type)]
pub struct ProfileUpdatePayload { pub struct ProfileUpdatePayload {
pub uid: String, pub uid: std::string::String,
} }
+7 -11
View File
@@ -2,8 +2,6 @@ use crate::utils::dirs;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity}; use clash_verge_service_ipc::{OwnerCredentials, OwnerIdentity};
use std::path::Path; use std::path::Path;
#[cfg(windows)]
use tristr::alias::String;
pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> { pub(crate) fn current_owner_credentials() -> Result<OwnerCredentials> {
current_owner_credentials_for_root(&dirs::app_home_dir()?) current_owner_credentials_for_root(&dirs::app_home_dir()?)
@@ -41,16 +39,16 @@ pub(crate) fn current_owner_credentials_for_root(app_root: &Path) -> Result<Owne
} }
#[cfg(windows)] #[cfg(windows)]
fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<std::string::String>)> { fn windows_owner_credentials(app_data_root: &Path) -> Result<(OwnerIdentity, Option<String>)> {
let sid = windows_owner::current_sid()?; let sid = windows_owner::current_sid()?;
let token = windows_owner::load_or_create_token(app_data_root, &sid)?; let token = windows_owner::load_or_create_token(app_data_root, &sid)?;
Ok((OwnerIdentity::Windows { sid: sid.into() }, Some(token.into()))) Ok((OwnerIdentity::Windows { sid }, Some(token)))
} }
#[cfg(windows)] #[cfg(windows)]
fn windows_current_identity() -> Result<OwnerIdentity> { fn windows_current_identity() -> Result<OwnerIdentity> {
Ok(OwnerIdentity::Windows { Ok(OwnerIdentity::Windows {
sid: windows_owner::current_sid()?.into(), sid: windows_owner::current_sid()?,
}) })
} }
@@ -81,7 +79,7 @@ pub(crate) fn repair_app_data_root_owner(app_data_root: &Path) -> Result<()> {
#[cfg(windows)] #[cfg(windows)]
pub(crate) fn current_user_pipe_sddl() -> Result<String> { pub(crate) fn current_user_pipe_sddl() -> Result<String> {
let sid = windows_owner::current_sid()?; let sid = windows_owner::current_sid()?;
Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)").into()) Ok(format!("D:P(A;;GA;;;{sid})(A;;GA;;;SY)(A;;GA;;;BA)"))
} }
#[cfg(windows)] #[cfg(windows)]
@@ -646,19 +644,17 @@ mod windows_owner {
} }
let value = LocalWideString(value); let value = LocalWideString(value);
let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count(); let length = (0..).take_while(|index| unsafe { *value.0.add(*index) } != 0).count();
// from_utf16 exists only on the std String type; the tristr alias shadows the bare name. String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
std::string::String::from_utf16(unsafe { std::slice::from_raw_parts(value.0, length) })
.context("process SID is not valid UTF-16") .context("process SID is not valid UTF-16")
.map(Into::into)
} }
fn encode_token(token: &[u8; TOKEN_BYTES]) -> String { fn encode_token(token: &[u8; TOKEN_BYTES]) -> String {
let mut encoded = std::string::String::with_capacity(TOKEN_BYTES * 2); let mut encoded = String::with_capacity(TOKEN_BYTES * 2);
for byte in token { for byte in token {
use std::fmt::Write as _; use std::fmt::Write as _;
let _ = write!(encoded, "{byte:02x}"); let _ = write!(encoded, "{byte:02x}");
} }
encoded.into() encoded
} }
fn wide_path(path: &Path) -> Result<Vec<u16>> { fn wide_path(path: &Path) -> Result<Vec<u16>> {
+10 -20
View File
@@ -6,7 +6,7 @@ use crate::{
notification::{self, FailedOperation}, notification::{self, FailedOperation},
runstate::RUN_STATE, runstate::RUN_STATE,
service, service,
sysopt::Sysopt, sysopt::{Sysopt, format_bypass},
}, },
process::AsyncHandler, process::AsyncHandler,
utils::{ utils::{
@@ -24,7 +24,6 @@ use std::{
time::Duration, time::Duration,
}; };
use tokio::{sync::Mutex, time::timeout}; use tokio::{sync::Mutex, time::timeout};
use tristr::alias::String;
const CLEAR_ATTEMPTS: u32 = 3; const CLEAR_ATTEMPTS: u32 = 3;
const CLEAR_RETRY_DELAY: Duration = Duration::from_millis(100); const CLEAR_RETRY_DELAY: Duration = Duration::from_millis(100);
@@ -205,8 +204,6 @@ const fn refusal_classification(service_usable: Option<bool>) -> SysproxyFailure
} }
const LOOPBACK_HOST: &str = "127.0.0.1"; const LOOPBACK_HOST: &str = "127.0.0.1";
const MACOS_DEFAULT_BYPASS: &str =
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
const MAX_SERVICE_BYPASS_LEN: usize = 8192; const MAX_SERVICE_BYPASS_LEN: usize = 8192;
static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new(); static SERVICE_PROXY_OPERATIONS: ServiceProxyOperations = ServiceProxyOperations::new();
@@ -321,21 +318,14 @@ fn truncate_utf8(value: &mut String, max_bytes: usize) {
while !value.is_char_boundary(boundary) { while !value.is_char_boundary(boundary) {
boundary -= 1; boundary -= 1;
} }
let truncated = value.as_str()[..boundary].into(); value.truncate(boundary);
*value = truncated;
} }
fn service_bypass(verge: &IVerge) -> Result<String> { fn service_bypass(verge: &IVerge) -> Result<String> {
let custom = verge.system_proxy_bypass.as_deref().unwrap_or(""); let custom = verge.system_proxy_bypass.as_deref().unwrap_or("");
ensure!(!custom.contains('\0'), "system proxy bypass contains NUL"); ensure!(!custom.contains('\0'), "system proxy bypass contains NUL");
let mut bypass = if custom.is_empty() { let mut bypass: String = format_bypass(verge.use_default_bypass.unwrap_or(true), custom).into();
MACOS_DEFAULT_BYPASS.into()
} else if verge.use_default_bypass.unwrap_or(true) {
format!("{MACOS_DEFAULT_BYPASS},{custom}").into()
} else {
custom.into()
};
truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN); truncate_utf8(&mut bypass, MAX_SERVICE_BYPASS_LEN);
Ok(bypass) Ok(bypass)
} }
@@ -355,7 +345,7 @@ fn service_proxy_config(verge: &IVerge, mixed_port: u16, pac_port: u16) -> Resul
Ok(MacosProxyConfig::Global { Ok(MacosProxyConfig::Global {
host: LOOPBACK_HOST.to_owned(), host: LOOPBACK_HOST.to_owned(),
port: mixed_port, port: mixed_port,
bypass: service_bypass(verge)?.into(), bypass: service_bypass(verge)?,
}) })
} }
} }
@@ -370,7 +360,7 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome
// Direct fallback satisfies a disable request. // Direct fallback satisfies a disable request.
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()), ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
ProxyApplyOutcome::DirectFallback { message } => { ProxyApplyOutcome::DirectFallback { message } => {
Err(SysproxyFailure::DirectFallback { detail: message.into() }.into()) Err(SysproxyFailure::DirectFallback { detail: message }.into())
} }
} }
} }
@@ -646,7 +636,7 @@ const GUARD_FAILURES_BEFORE_STOPPING: u32 = 3;
async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) { async fn report_guard_stopped(captured_generation: u64, error: &anyhow::Error) {
let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped { let stopped = anyhow::Error::new(SysproxyFailure::GuardStopped {
detail: format!("{error:#}").into(), detail: format!("{error:#}"),
}); });
// Do not publish a late failure from a replaced guard. // Do not publish a late failure from a replaced guard.
let recorded = SERVICE_PROXY_OPERATIONS let recorded = SERVICE_PROXY_OPERATIONS
@@ -1108,7 +1098,7 @@ mod tests {
let requested = MacosProxyConfig::Global { let requested = MacosProxyConfig::Global {
host: "127.0.0.1".to_owned(), host: "127.0.0.1".to_owned(),
port: 7890, port: 7890,
bypass: std::string::String::new(), bypass: String::new(),
}; };
let error = service_apply_result( let error = service_apply_result(
@@ -1148,8 +1138,8 @@ mod tests {
let events = Mutex::new(Vec::new()); let events = Mutex::new(Vec::new());
super::settle_table( super::settle_table(
effect, effect,
|asked| events.lock().push(format!("retire:{asked:?}").into()), |asked| events.lock().push(format!("retire:{asked:?}")),
|operation, _| events.lock().push(format!("file:{operation:?}").into()), |operation, _| events.lock().push(format!("file:{operation:?}")),
); );
events.into_inner() events.into_inner()
} }
@@ -1278,7 +1268,7 @@ mod tests {
let combined = rollback_failure( let combined = rollback_failure(
mappable(), mappable(),
anyhow::anyhow!("the service could not set it").context(SysproxyFailure::DirectFallback { anyhow::anyhow!("the service could not set it").context(SysproxyFailure::DirectFallback {
detail: "fell back to direct".into(), detail: "fell back to direct".to_owned(),
}), }),
); );
+14 -23
View File
@@ -4,7 +4,6 @@ use serde::Serialize;
use tauri_plugin_mihomo::models::{ use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
}; };
use tristr::alias::String;
pub struct ProxyViewInput { pub struct ProxyViewInput {
pub runtime_group_order: Vec<String>, pub runtime_group_order: Vec<String>,
@@ -271,7 +270,7 @@ impl ProxyViewBuilder {
let global = core_groups let global = core_groups
.remove("GLOBAL") .remove("GLOBAL")
.map(|proxy| build_group(String::from("GLOBAL"), proxy, &resolver)); .map(|proxy| build_group("GLOBAL".to_owned(), proxy, &resolver));
let (groups, order_source) = build_ordered_groups(core_groups, runtime_group_order, &resolver); let (groups, order_source) = build_ordered_groups(core_groups, runtime_group_order, &resolver);
let direct = core_node_ids.get("DIRECT").cloned(); let direct = core_node_ids.get("DIRECT").cloned();
let standalone = build_standalone(&core_node_ids); let standalone = build_standalone(&core_node_ids);
@@ -295,7 +294,6 @@ fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String
let mut nodes = BTreeMap::new(); let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies { for (name, proxy) in proxies.proxies {
let name = String::from(name);
if proxy.all.is_some() { if proxy.all.is_some() {
groups.insert(name, proxy); groups.insert(name, proxy);
} else { } else {
@@ -313,7 +311,7 @@ fn build_core_records(
let mut ids = BTreeMap::new(); let mut ids = BTreeMap::new();
for (index, (name, proxy)) in core_nodes.into_iter().enumerate() { for (index, (name, proxy)) in core_nodes.into_iter().enumerate() {
let record_id = String::from(format!("c:{index}")); let record_id = format!("c:{index}");
ids.insert(name.clone(), record_id.clone()); ids.insert(name.clone(), record_id.clone());
records.insert( records.insert(
record_id.clone(), record_id.clone(),
@@ -334,13 +332,7 @@ fn build_provider_records(
records: &mut BTreeMap<String, ProxyNodeView>, records: &mut BTreeMap<String, ProxyNodeView>,
) -> (Vec<ProxyProviderView>, ProviderCandidates) { ) -> (Vec<ProxyProviderView>, ProviderCandidates) {
let providers = providers let providers = providers
.map(|providers| { .map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
providers
.providers
.into_iter()
.map(|(name, provider)| (String::from(name), provider))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default(); .unwrap_or_default();
let mut views = Vec::new(); let mut views = Vec::new();
let mut candidates = BTreeMap::new(); let mut candidates = BTreeMap::new();
@@ -363,9 +355,9 @@ fn build_provider_records(
let mut proxy_record_ids = Vec::new(); let mut proxy_record_ids = Vec::new();
for (member_index, proxy) in proxies.into_iter().enumerate() { for (member_index, proxy) in proxies.into_iter().enumerate() {
let record_id = String::from(format!("p:{provider_index}:{member_index}")); let record_id = format!("p:{provider_index}:{member_index}");
let proxy_name = String::from(proxy.name.as_str()); let proxy_name = proxy.name.clone();
let node_name = String::from(proxy.name.as_str()); let node_name = proxy.name.clone();
candidates candidates
.entry(proxy_name.clone()) .entry(proxy_name.clone())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
@@ -388,7 +380,7 @@ fn build_provider_records(
views.push(ProxyProviderView { views.push(ProxyProviderView {
name: provider_name, name: provider_name,
vehicle_type, vehicle_type,
updated_at: updated_at.map(String::from), updated_at,
subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo { subscription_info: subscription_info.map(|subscription_info| ProxySubscriptionInfo {
upload: subscription_info.upload, upload: subscription_info.upload,
download: subscription_info.download, download: subscription_info.download,
@@ -425,7 +417,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|member| { .map(|member| {
let member = String::from(member);
let (occurrence, scoped) = seen let (occurrence, scoped) = seen
.entry(member.clone()) .entry(member.clone())
.or_insert_with(|| (0, resolver.scoped_candidates(&name, &member))); .or_insert_with(|| (0, resolver.scoped_candidates(&name, &member)));
@@ -438,11 +429,11 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
ProxyGroupView { ProxyGroupView {
name, name,
proxy_type, proxy_type,
now: now.map(String::from), now,
fixed: fixed.map(String::from), fixed,
hidden, hidden,
icon: icon.map(String::from), icon,
test_url: test_url.map(String::from), test_url,
history, history,
capabilities: ProxyCapabilities { capabilities: ProxyCapabilities {
udp, udp,
@@ -476,10 +467,10 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name, name,
proxy_type, proxy_type,
history, history,
id: id.map(String::from), id,
hidden, hidden,
icon: icon.map(String::from), icon,
test_url: test_url.map(String::from), test_url,
capabilities: ProxyCapabilities { capabilities: ProxyCapabilities {
udp, udp,
xudp, xudp,
+7 -8
View File
@@ -64,7 +64,7 @@ impl RunStateEnv for RealEnv {
Ok(ServiceVersionReply { Ok(ServiceVersionReply {
core, core,
code: response.code, code: response.code,
message: response.message.into(), message: response.message,
protocol: response.data, protocol: response.data,
}) })
} }
@@ -123,7 +123,6 @@ mod fake {
use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply}; use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply};
use crate::core::manager::RunningMode; use crate::core::manager::RunningMode;
use tristr::alias::String;
/// A fail-closed scripted machine that records outbound effects. /// A fail-closed scripted machine that records outbound effects.
#[derive(Debug)] #[derive(Debug)]
@@ -164,7 +163,7 @@ mod fake {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply { self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()), protocol: Some(ProtocolInfo::current()),
})) }))
} }
@@ -174,7 +173,7 @@ mod fake {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply { self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: None, protocol: None,
})) }))
} }
@@ -182,12 +181,12 @@ mod fake {
#[must_use] #[must_use]
pub fn service_unreachable(self) -> Self { pub fn service_unreachable(self) -> Self {
self.with_evidence(true) self.with_evidence(true)
.always_replying(Err("ipc transport refused".into())) .always_replying(Err("ipc transport refused".to_owned()))
} }
#[must_use] #[must_use]
pub fn evidence_unavailable(mut self) -> Self { pub fn evidence_unavailable(mut self) -> Self {
self.evidence = Err("registry probe failed".into()); self.evidence = Err("registry probe failed".to_owned());
self self
} }
@@ -237,7 +236,7 @@ mod fake {
#[must_use] #[must_use]
pub fn privileged_operations_fail(self, reason: &str) -> Self { pub fn privileged_operations_fail(self, reason: &str) -> Self {
*self.privileged_outcome.lock() = Err(reason.into()); *self.privileged_outcome.lock() = Err(reason.to_owned());
self self
} }
@@ -257,7 +256,7 @@ mod fake {
replies replies
.first() .first()
.cloned() .cloned()
.unwrap_or_else(|| Err("no service configured".into())) .unwrap_or_else(|| Err("no service configured".to_owned()))
}; };
reply.map_err(|error| anyhow!(error)) reply.map_err(|error| anyhow!(error))
} }
-1
View File
@@ -2,7 +2,6 @@
//! Core lifecycle remains in `CoreManager`. //! Core lifecycle remains in `CoreManager`.
use crate::core::manager::RunningMode; use crate::core::manager::RunningMode;
use tristr::alias::String;
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ServiceHealth { pub enum ServiceHealth {
+5 -5
View File
@@ -173,7 +173,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
Type::Service, Type::Service,
"failed to inspect trusted service evidence: {error:#}" "failed to inspect trusted service evidence: {error:#}"
); );
return ServiceHealth::Unavailable(format!("service detection failed: {error:#}").into()); return ServiceHealth::Unavailable(format!("service detection failed: {error:#}"));
} }
}; };
@@ -331,7 +331,7 @@ impl<E: RunStateEnv> RunStateStore<E> {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: tristr::alias::String) -> Result<()> { pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
let mut state = self.service.lock(); let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() { if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending"); bail!("cannot fall back while a service operation is pending");
@@ -496,7 +496,7 @@ mod tests {
ServiceVersionReply { ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()), protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
} }
} }
@@ -713,7 +713,7 @@ mod tests {
for health in [ for health in [
ServiceHealth::NotInstalled, ServiceHealth::NotInstalled,
ServiceHealth::VersionMismatch, ServiceHealth::VersionMismatch,
ServiceHealth::Unavailable("boom".into()), ServiceHealth::Unavailable("boom".to_owned()),
] { ] {
let store = with_env(FakeEnv::new()); let store = with_env(FakeEnv::new());
store.observe(health.clone()); store.observe(health.clone());
@@ -858,7 +858,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn awaiting_readiness_retries_a_silent_service_then_accepts_its_reply() { async fn awaiting_readiness_retries_a_silent_service_then_accepts_its_reply() {
let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".into()), Ok(ready_reply())])); let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".to_owned()), Ok(ready_reply())]));
let state = store let state = store
.await_ready(2, Duration::ZERO) .await_ready(2, Duration::ZERO)
+12 -16
View File
@@ -9,7 +9,6 @@ use clash_verge_service_ipc::{
}; };
use super::health::ServiceHealth; use super::health::ServiceHealth;
use tristr::alias::String;
/// What the Service replied to a protocol-version query. /// What the Service replied to a protocol-version query.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -51,7 +50,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
return match &reply.core { return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready, Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => { Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}").into()) ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
} }
_ => ServiceVersionCheck::CoreUnavailable( _ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(), "the selected approved core is missing or does not match; reinstall the service to repair it".into(),
@@ -78,12 +77,9 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
reply.code, reply.message, client.epoch reply.code, reply.message, client.epoch
) )
}; };
ServiceVersionCheck::NeedsReinstall( ServiceVersionCheck::NeedsReinstall(format!(
format!( "Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version" ))
)
.into(),
)
} }
/// Classify a version reply into the probe outcome used by [`classify_service_health`]. /// Classify a version reply into the probe outcome used by [`classify_service_health`].
@@ -108,7 +104,7 @@ pub fn classify_service_health(
match current { match current {
CurrentServiceProbe::Ready => ServiceHealth::Ready, CurrentServiceProbe::Ready => ServiceHealth::Ready,
CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch, CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.into()), CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.to_owned()),
CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch, CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch,
CurrentServiceProbe::Missing => ServiceHealth::NotInstalled, CurrentServiceProbe::Missing => ServiceHealth::NotInstalled,
} }
@@ -123,7 +119,7 @@ mod tests {
ServiceVersionReply { ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()), protocol: Some(ProtocolInfo::current()),
} }
} }
@@ -158,7 +154,7 @@ mod tests {
let reply = ServiceVersionReply { let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: None, protocol: None,
}; };
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -176,7 +172,7 @@ mod tests {
let reply = ServiceVersionReply { let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 7, code: 7,
message: "nope".into(), message: "nope".to_owned(),
protocol: Some(ProtocolInfo::current()), protocol: Some(ProtocolInfo::current()),
}; };
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -192,7 +188,7 @@ mod tests {
let reply = ServiceVersionReply { let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: Some(info), protocol: Some(info),
}; };
assert!(matches!( assert!(matches!(
@@ -213,7 +209,7 @@ mod tests {
let reply = ServiceVersionReply { let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready), core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0, code: 0,
message: "ok".into(), message: "ok".to_owned(),
protocol: Some(info), protocol: Some(info),
}; };
assert!(matches!( assert!(matches!(
@@ -232,7 +228,7 @@ mod tests {
let reply = ServiceVersionReply { let reply = ServiceVersionReply {
core: None, core: None,
code, code,
message: "unsupported command".into(), message: "unsupported command".to_owned(),
protocol: protocol.clone(), protocol: protocol.clone(),
}; };
let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else {
@@ -254,7 +250,7 @@ mod tests {
fn an_unreachable_service_keeps_the_reason() { fn an_unreachable_service_keeps_the_reason() {
assert_eq!( assert_eq!(
classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"), classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"),
ServiceHealth::Unavailable("socket refused".into()) ServiceHealth::Unavailable("socket refused".to_owned())
); );
} }
+86 -18
View File
@@ -4,8 +4,7 @@ use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf, Prefix};
use tristr::alias::String;
pub(crate) const GEO_ASSETS: &[&str] = &[ pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb", "Country.mmdb",
@@ -65,7 +64,7 @@ struct ProviderPathOwners {
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> { pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
let config_root = std::fs::canonicalize(config_root)?; let config_root = std::fs::canonicalize(config_root)?;
let mut paths = BTreeMap::<String, ProviderPathOwners>::new(); let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).into()).collect(); let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
for section in ["proxy-providers", "rule-providers"] { for section in ["proxy-providers", "rule-providers"] {
let Some(providers) = config.get(section).and_then(Value::as_mapping) else { let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
continue; continue;
@@ -90,7 +89,7 @@ pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root:
match (is_remote, provider.get("url").and_then(Value::as_str)) { match (is_remote, provider.get("url").and_then(Value::as_str)) {
(true, Some(url)) => owners (true, Some(url)) => owners
.remote .remote
.entry(url.into()) .entry(url.to_owned())
.or_default() .or_default()
.push((section, name.clone())), .push((section, name.clone())),
_ => owners.local = true, _ => owners.local = true,
@@ -106,7 +105,7 @@ pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root:
for (url, providers) in owners.remote { for (url, providers) in owners.remote {
let replacement = allocate_provider_destination(&destination, &url, &mut reserved); let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
for (section, name) in providers { for (section, name) in providers {
config[section][&name]["path"] = Value::String(replacement.clone().into()); config[section][&name]["path"] = Value::String(replacement.clone());
logging!( logging!(
warn, warn,
Type::Config, Type::Config,
@@ -132,16 +131,15 @@ fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut Ha
let mut suffix = String::new(); let mut suffix = String::new();
let mut sequence = 0; let mut sequence = 0;
loop { loop {
let candidate: String = path let candidate = path
.with_file_name(format!("cvr-{digest}{suffix}{extension}")) .with_file_name(format!("cvr-{digest}{suffix}{extension}"))
.to_string_lossy() .to_string_lossy()
.replace('\\', "/") .replace('\\', "/");
.into();
if reserved.insert(candidate.clone()) { if reserved.insert(candidate.clone()) {
return candidate; return candidate;
} }
sequence += 1; sequence += 1;
suffix = format!("-{sequence}").into(); suffix = format!("-{sequence}");
} }
} }
@@ -167,9 +165,10 @@ fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<Runtim
&mut assets, &mut assets,
&mut remote_providers, &mut remote_providers,
)?; )?;
collect_ssh_private_keys(&mut config, &config_root, &mut destinations, &mut assets);
for filename in GEO_ASSETS { for filename in GEO_ASSETS {
let source = config_root.join(filename); let source = config_root.join(filename);
if source.is_file() && destinations.insert((*filename).into()) { if source.is_file() && destinations.insert((*filename).to_string()) {
assets.push(RuntimeAsset { assets.push(RuntimeAsset {
source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(), source: std::fs::canonicalize(&source)?.to_string_lossy().into_owned(),
destination: (*filename).to_string(), destination: (*filename).to_string(),
@@ -230,9 +229,9 @@ fn collect_provider_assets(
} }
remote_providers.push(RemoteProviderRef { remote_providers.push(RemoteProviderRef {
section, section,
name: name.as_str().unwrap_or_default().into(), name: name.as_str().unwrap_or_default().to_owned(),
provider: RemoteProvider { provider: RemoteProvider {
destination: destination.clone().into(), destination: destination.clone(),
url, url,
}, },
}); });
@@ -261,17 +260,86 @@ fn collect_provider_assets(
if destinations.insert(destination.clone()) { if destinations.insert(destination.clone()) {
assets.push(RuntimeAsset { assets.push(RuntimeAsset {
source: source.to_string_lossy().into_owned(), source: source.to_string_lossy().into_owned(),
destination: destination.clone().into(), destination: destination.clone(),
}); });
} }
destination destination
} }
}; };
provider.insert(Value::String("path".to_owned()), Value::String(destination.into())); provider.insert(Value::String("path".to_owned()), Value::String(destination));
} }
Ok(()) Ok(())
} }
/// Stages file-based ssh keys, which mihomo resolves against the core home directory.
fn collect_ssh_private_keys(
config: &mut Value,
config_root: &Path,
destinations: &mut HashSet<String>,
assets: &mut Vec<RuntimeAsset>,
) {
let Some(config) = config.as_mapping_mut() else {
return;
};
// mihomo parses `payload` for every provider type, eagerly as fallback proxies for file and http.
let mut proxy_lists = Vec::new();
for (key, value) in config.iter_mut() {
match key.as_str() {
Some("proxies") => proxy_lists.extend(value.as_sequence_mut()),
Some("proxy-providers") => proxy_lists.extend(
value
.as_mapping_mut()
.into_iter()
.flat_map(Mapping::values_mut)
.filter_map(|provider| provider.get_mut("payload").and_then(Value::as_sequence_mut)),
),
_ => {}
}
}
for proxy in proxy_lists.into_iter().flatten().filter_map(Value::as_mapping_mut) {
if proxy.get("type").and_then(Value::as_str) != Some("ssh") {
continue;
}
// mihomo reads any value containing "PRIVATE KEY" as inline key material.
let Some(raw_path) = proxy
.get("private-key")
.and_then(Value::as_str)
.filter(|key| !key.is_empty() && !key.contains("PRIVATE KEY") && is_local_path(key))
else {
continue;
};
let Some(source) = local_provider_source(config_root, raw_path)
.ok()
.filter(|source| source.is_file())
else {
let name = proxy.get("name").and_then(Value::as_str).unwrap_or_default();
logging!(
warn,
Type::Config,
"ssh proxy {name:?} private key is unavailable below the config root; leaving it to the core"
);
continue;
};
// A root-level alias keeps the user's path away from service name rules and mihomo's inline-key test.
let source = source.to_string_lossy().into_owned();
let destination = allocate_provider_destination("ssh-private-key", &source, destinations);
assets.push(RuntimeAsset {
source,
destination: destination.clone(),
});
proxy.insert(Value::String("private-key".to_owned()), Value::String(destination));
}
}
/// Resolving a UNC or device path would authenticate to its server, so those stay with the core.
fn is_local_path(raw_path: &str) -> bool {
match Path::new(raw_path).components().next() {
Some(Component::Prefix(prefix)) => matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)),
_ => true,
}
}
fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> { fn local_provider_source(config_root: &Path, raw_path: &str) -> Result<PathBuf> {
let path = Path::new(raw_path); let path = Path::new(raw_path);
let source = if path.is_absolute() { let source = if path.is_absolute() {
@@ -341,7 +409,7 @@ fn normalized_destination(relative: &Path) -> Result<String> {
if destination.as_os_str().is_empty() { if destination.as_os_str().is_empty() {
bail!("local runtime provider destination is empty"); bail!("local runtime provider destination is empty");
} }
Ok(destination.to_string_lossy().replace('\\', "/").into()) Ok(destination.to_string_lossy().replace('\\', "/"))
} }
#[cfg(test)] #[cfg(test)]
@@ -387,12 +455,12 @@ mod tests {
for declared in readback { for declared in readback {
assert!(bundle.remote_providers.contains(&declared.provider)); assert!(bundle.remote_providers.contains(&declared.provider));
assert_eq!( assert_eq!(
config[declared.section][declared.name.as_str()]["path"], config[declared.section][&declared.name]["path"],
declared.provider.destination declared.provider.destination
); );
assert_eq!( assert_eq!(
config[declared.section][declared.name.as_str()]["url"], config[declared.section][&declared.name]["url"],
original[declared.section][declared.name.as_str()]["url"] original[declared.section][&declared.name]["url"]
); );
} }
std::fs::remove_dir_all(root)?; std::fs::remove_dir_all(root)?;
+138 -76
View File
@@ -13,7 +13,7 @@ use crate::{
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth, ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
}, },
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of}, runtime_bundle::{GEO_ASSETS, RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray, tray::Tray,
}, },
process::AsyncHandler, process::AsyncHandler,
@@ -36,7 +36,6 @@ use std::{
sync::atomic::{AtomicBool, AtomicU64, Ordering}, sync::atomic::{AtomicBool, AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH}, time::{Duration, SystemTime, UNIX_EPOCH},
}; };
use tristr::alias::String;
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None); static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(None);
@@ -63,7 +62,7 @@ pub(crate) fn is_not_auto_started(reason: &str) -> bool {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback(reason: &str) { pub(crate) fn notify_service_fallback(reason: &str) {
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.into()); *PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, ""); Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
} }
@@ -92,7 +91,7 @@ fn app_data_owner_command(credentials: &OwnerCredentials) -> Option<String> {
return None; return None;
}; };
let path = credentials.app_data_dir.replace('\'', r"'\''"); let path = credentials.app_data_dir.replace('\'', r"'\''");
Some(format!("sudo chown -R {uid}:{gid} '{path}'").into()) Some(format!("sudo chown -R {uid}:{gid} '{path}'"))
} }
/// Capabilities of the service session that owns the running Core. /// Capabilities of the service session that owns the running Core.
@@ -102,6 +101,7 @@ struct ActiveServiceSession {
proof: OwnerSessionProof, proof: OwnerSessionProof,
supports_runtime_staging: bool, supports_runtime_staging: bool,
supports_runtime_file_read: bool, supports_runtime_file_read: bool,
supports_runtime_geo_read: bool,
} }
fn generate_service_session_token() -> Result<String> { fn generate_service_session_token() -> Result<String> {
@@ -133,6 +133,13 @@ pub(crate) fn active_service_supports_runtime_file_read() -> bool {
.is_some_and(|session| session.supports_runtime_file_read) .is_some_and(|session| session.supports_runtime_file_read)
} }
fn active_service_supports_runtime_geo_read() -> bool {
ACTIVE_SERVICE_SESSION
.lock()
.as_ref()
.is_some_and(|session| session.supports_runtime_geo_read)
}
pub(crate) fn clear_active_service_session() { pub(crate) fn clear_active_service_session() {
ACTIVE_SERVICE_SESSION.lock().take(); ACTIVE_SERVICE_SESSION.lock().take();
} }
@@ -141,6 +148,7 @@ pub(crate) fn clear_active_service_session() {
struct ServiceCapabilities { struct ServiceCapabilities {
runtime_staging: bool, runtime_staging: bool,
runtime_file_read: bool, runtime_file_read: bool,
runtime_geo_read: bool,
} }
impl ServiceCapabilities { impl ServiceCapabilities {
@@ -148,6 +156,7 @@ impl ServiceCapabilities {
Self { Self {
runtime_staging: info.supports_runtime_staging(), runtime_staging: info.supports_runtime_staging(),
runtime_file_read: info.supports_runtime_file_read(), runtime_file_read: info.supports_runtime_file_read(),
runtime_geo_read: info.supports_runtime_geo_read(),
} }
} }
} }
@@ -244,24 +253,22 @@ fn macos_service_install_markers() -> Vec<String> {
format!( format!(
"/Library/LaunchDaemons/{}.plist", "/Library/LaunchDaemons/{}.plist",
clash_verge_service_ipc::MACOS_SERVICE_ID clash_verge_service_ipc::MACOS_SERVICE_ID
) ),
.into(),
format!( format!(
"/Library/PrivilegedHelperTools/{}.bundle", "/Library/PrivilegedHelperTools/{}.bundle",
clash_verge_service_ipc::MACOS_SERVICE_ID clash_verge_service_ipc::MACOS_SERVICE_ID
) ),
.into(),
#[cfg(not(feature = "verge-dev"))] #[cfg(not(feature = "verge-dev"))]
"/Library/LaunchDaemons/io.github.clashverge.helper.plist".into(), "/Library/LaunchDaemons/io.github.clashverge.helper.plist".to_owned(),
#[cfg(not(feature = "verge-dev"))] #[cfg(not(feature = "verge-dev"))]
"/Library/PrivilegedHelperTools/io.github.clashverge.helper".into(), "/Library/PrivilegedHelperTools/io.github.clashverge.helper".to_owned(),
] ]
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn macos_service_install_marker_exists() -> std::io::Result<bool> { fn macos_service_install_marker_exists() -> std::io::Result<bool> {
for marker in macos_service_install_markers() { for marker in macos_service_install_markers() {
if path_entry_exists_without_follow(Path::new(marker.as_str()))? { if path_entry_exists_without_follow(Path::new(&marker))? {
return Ok(true); return Ok(true);
} }
} }
@@ -338,8 +345,7 @@ pub(crate) fn trusted_service_evidence() -> Result<bool> {
output.status output.status
); );
} }
// from_utf8_lossy exists only on the std String type; the tristr alias shadows the bare name. Ok(String::from_utf8_lossy(&output.stdout).trim() != "not-found")
Ok(std::string::String::from_utf8_lossy(&output.stdout).trim() != "not-found")
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -568,12 +574,12 @@ fn service_core_path(clash_core: &str, bin_ext: &str) -> Result<PathBuf> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn escape_osascript_double_quoted_string(value: &str) -> String { fn escape_osascript_double_quoted_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"").into() value.replace('\\', "\\\\").replace('"', "\\\"")
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn shell_single_quote(value: &str) -> String { fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''")).into() format!("'{}'", value.replace('\'', r"'\''"))
} }
fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> { fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
@@ -597,55 +603,76 @@ fn packaged_service_tool_path(file_name: &str, packaged_path: impl FnOnce() -> R
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> { pub(super) fn run_maybe_elevated(program: &Path, hide_window: bool) -> Result<std::process::ExitStatus> {
logging!(info, Type::Service, "uninstall service");
use deelevate::{PrivilegeLevel, Token}; use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand; use runas::Command as RunasCommand;
use std::os::windows::process::CommandExt as _; use std::os::windows::process::CommandExt as _;
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let token = Token::with_current_process()?; let token = Token::with_current_process()?;
let level = token.privilege_level()?; let level = token.privilege_level()?;
let status = match level { let status = match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(uninstall_path).show(false).status()?, PrivilegeLevel::NotPrivileged => {
_ => StdCommand::new(uninstall_path).creation_flags(0x08000000).status()?, let mut command = RunasCommand::new(program);
if hide_window {
command.show(false);
}
command.status()?
}
_ => {
let mut command = StdCommand::new(program);
if hide_window {
// 0x08000000 is CREATE_NO_WINDOW, the direct counterpart of runas's show(false).
command.creation_flags(0x08000000);
}
command.status()?
}
}; };
Ok(status)
}
fn uninstaller_tool_path(file_name: &str, packaged_path: impl FnOnce() -> Result<PathBuf>) -> Result<PathBuf> {
let uninstall_path = packaged_service_tool_path(file_name, packaged_path)?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
Ok(uninstall_path)
}
fn ensure_uninstall_success(status: &std::process::ExitStatus) -> Result<()> {
if !status.success() { if !status.success() {
bail!( bail!(
"failed to uninstall service with status {}", "failed to uninstall service with status {}",
status.code().unwrap_or(-1) status.code().unwrap_or(-1)
); );
} }
Ok(()) Ok(())
} }
#[cfg(target_os = "windows")]
fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service");
let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall.exe", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall.exe"))
})?;
let status = run_maybe_elevated(&uninstall_path, true)?;
ensure_uninstall_success(&status)
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn uninstall_service() -> Result<()> { fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service"); logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall")) Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-uninstall"))
})?; })?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let elevator = crate::utils::help::linux_elevator(); let elevator = crate::utils::help::linux_elevator();
let status = if linux_running_as_root() { let status = if linux_running_as_root() {
StdCommand::new(&uninstall_path).status()? StdCommand::new(&uninstall_path).status()?
} else { } else {
let result = StdCommand::new(elevator.as_str()) let result = StdCommand::new(&elevator)
.arg("--disable-internal-agent") .arg("--disable-internal-agent")
.arg(&uninstall_path) .arg(&uninstall_path)
.status()?; .status()?;
@@ -670,14 +697,7 @@ fn uninstall_service() -> Result<()> {
status.code().unwrap_or(-1) status.code().unwrap_or(-1)
); );
if !status.success() { ensure_uninstall_success(&status)
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -692,16 +712,12 @@ fn linux_running_as_root() -> bool {
fn uninstall_service() -> Result<()> { fn uninstall_service() -> Result<()> {
logging!(info, Type::Service, "uninstall service"); logging!(info, Type::Service, "uninstall service");
let uninstall_path = packaged_service_tool_path("clash-verge-service-uninstall", || { let uninstall_path = uninstaller_tool_path("clash-verge-service-uninstall", || {
Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall")) Ok(dirs::service_path()?.with_file_name("clash-verge-service-uninstall"))
})?; })?;
if !uninstall_path.exists() {
bail!(format!("uninstaller not found: {uninstall_path:?}"));
}
let uninstall_path = macos_service_tool_path(&uninstall_path)?; let uninstall_path = macos_service_tool_path(&uninstall_path)?;
let uninstall_shell: String = uninstall_path.to_string_lossy().into(); let uninstall_shell: String = uninstall_path.to_string_lossy().into_owned();
// clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref()); // clash_verge_i18n::sync_locale(Config::verge().await.latest_arc().language.as_deref());
@@ -715,14 +731,7 @@ fn uninstall_service() -> Result<()> {
let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?; let status = StdCommand::new("osascript").args(vec!["-e", &command]).status()?;
if !status.success() { ensure_uninstall_success(&status)
bail!(
"failed to uninstall service with status {}",
status.code().unwrap_or(-1)
);
}
Ok(())
} }
fn install_service() -> Result<()> { fn install_service() -> Result<()> {
@@ -847,7 +856,7 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
if response.code > 0 { if response.code > 0 {
return Ok(StageRequest::Refused { return Ok(StageRequest::Refused {
code: response.code, code: response.code,
message: response.message.into(), message: response.message,
}); });
} }
response response
@@ -883,7 +892,7 @@ fn record_service_start_refusal<E: RunStateEnv>(
if store.state().mode == crate::core::manager::RunningMode::NotRunning if store.state().mode == crate::core::manager::RunningMode::NotRunning
&& refusal.code != ServiceErrorCode::ProxyClearFailed as u16 && refusal.code != ServiceErrorCode::ProxyClearFailed as u16
{ {
store.observe(ServiceHealth::Unavailable(refusal.to_string().into())); store.observe(ServiceHealth::Unavailable(refusal.to_string()));
} }
refusal.into() refusal.into()
} }
@@ -891,7 +900,7 @@ fn record_service_start_refusal<E: RunStateEnv>(
/// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair. /// Sidecar stays blocked until a reinstall replaces the residual helper, so ask for repair.
pub(super) fn record_residual_service(error: &anyhow::Error) { pub(super) fn record_residual_service(error: &anyhow::Error) {
if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() { if let Some(residual) = error.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>() {
RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string().into())); RUN_STATE.observe(ServiceHealth::Unavailable(residual.to_string()));
} }
} }
@@ -905,7 +914,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
let proposed_session_token = generate_service_session_token()?; let proposed_session_token = generate_service_session_token()?;
let request = StartClashRequest { let request = StartClashRequest {
runtime, runtime,
proposed_session_token: proposed_session_token.clone().into(), proposed_session_token: proposed_session_token.clone(),
macos_proxy: None, macos_proxy: None,
}; };
@@ -943,8 +952,8 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
&RUN_STATE, &RUN_STATE,
ServiceStartRefusal { ServiceStartRefusal {
code: response.code, code: response.code,
core_path: request.runtime.core_path.into(), core_path: request.runtime.core_path,
message: err_msg.into(), message: err_msg,
}, },
)); ));
} }
@@ -956,10 +965,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
*ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession { *ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession {
proof: OwnerSessionProof { proof: OwnerSessionProof {
generation: result.session.generation, generation: result.session.generation,
token: proposed_session_token.into(), token: proposed_session_token,
}, },
supports_runtime_staging: capabilities.runtime_staging, supports_runtime_staging: capabilities.runtime_staging,
supports_runtime_file_read: capabilities.runtime_file_read, supports_runtime_file_read: capabilities.runtime_file_read,
supports_runtime_geo_read: capabilities.runtime_geo_read,
}); });
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`. // PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
@@ -1016,10 +1026,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result<Vec<String>> {
bail!(err_msg); bail!(err_msg);
} }
Ok(response Ok(response.data.unwrap_or_default())
.data
.map(|logs| logs.into_iter().map(Into::into).collect())
.unwrap_or_default())
} }
pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> { pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
@@ -1037,8 +1044,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result<String> {
} }
let encoded = response.data.context("服务未返回核心日志快照")?; let encoded = response.data.context("服务未返回核心日志快照")?;
let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?; let content = decode_hex(&encoded).context("服务返回了无效的核心日志快照")?;
// from_utf8_lossy_owned exists only on the std String type; the tristr alias shadows the bare name. Ok(String::from_utf8_lossy_owned(content))
Ok(std::string::String::from_utf8_lossy_owned(content).into())
} }
fn decode_hex(encoded: &str) -> Result<Vec<u8>> { fn decode_hex(encoded: &str) -> Result<Vec<u8>> {
@@ -1214,6 +1220,62 @@ async fn sync_runtime_providers_by_service() -> Result<ProviderSync> {
Ok(outcome) Ok(outcome)
} }
/// Pulls geo databases the service core rewrote, because validation reads the app dir.
pub(crate) async fn sync_runtime_geo_by_service() {
if !active_service_supports_runtime_geo_read() {
return;
}
let (Ok(credentials), Ok(session), Ok(app_dir)) = (
current_owner_credentials(),
active_service_session(),
dirs::app_home_dir(),
) else {
return;
};
for asset in GEO_ASSETS {
if let Err(error) = sync_runtime_geo(&credentials, &session, asset, &app_dir.join(asset)).await {
logging!(warn, Type::Service, "failed to sync geo database {asset}: {error:#}");
}
}
}
async fn sync_runtime_geo(
credentials: &OwnerCredentials,
session: &OwnerSessionProof,
asset: &str,
target: &Path,
) -> Result<()> {
let Ok(local) = tokio::fs::metadata(target).await else {
return Ok(());
};
let Some((Some(_), _, Some(runtime_mtime_ns))) = read_chunk(credentials, session, asset, u64::MAX).await? else {
return Ok(());
};
// A newer local file is a change the next staging still has to copy into the runtime.
if UNIX_EPOCH + Duration::from_nanos(runtime_mtime_ns) <= local.modified()? {
return Ok(());
}
let (temp, file) = create_sync_temp(target).await?;
let result = async {
if let Fetch::Complete {
mtime_ns: Some(mtime_ns),
..
} = fetch_runtime_file(credentials, session, asset, file).await?
{
// Matching the runtime copy's time lets the service skip copying these bytes straight back.
std::fs::File::options()
.write(true)
.open(&temp)?
.set_modified(UNIX_EPOCH + Duration::from_nanos(mtime_ns))?;
tokio::fs::rename(&temp, target).await?;
}
anyhow::Ok(())
}
.await;
remove_temp(&temp).await;
result
}
// The on-disk YAML may describe a rejected apply; use the committed runtime. // The on-disk YAML may describe a rejected apply; use the committed runtime.
fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> { fn applied_remote_providers(runtime: &Draft<IRuntime>, app_dir: &Path) -> Result<Vec<RemoteProviderRef>> {
let applied = runtime.data_arc(); let applied = runtime.data_arc();
@@ -1352,7 +1414,7 @@ async fn read_chunk(
} }
Ok(Some(match response.data.context("服务未返回运行时文件")? { Ok(Some(match response.data.context("服务未返回运行时文件")? {
RuntimeFileOutcome::Absent => (None, 0, None), RuntimeFileOutcome::Absent => (None, 0, None),
RuntimeFileOutcome::Chunk { hex, len, mtime_ns } => (Some(hex.into()), len, mtime_ns), RuntimeFileOutcome::Chunk { hex, len, mtime_ns } => (Some(hex), len, mtime_ns),
})) }))
} }
@@ -1364,11 +1426,11 @@ async fn provider_load_times() -> Result<ProviderLoadTimes> {
let proxies = proxies.map_err(|error| anyhow!("failed to query proxy providers: {error}"))?; let proxies = proxies.map_err(|error| anyhow!("failed to query proxy providers: {error}"))?;
let mut loaded = ProviderLoadTimes::new(); let mut loaded = ProviderLoadTimes::new();
for (name, provider) in rules.providers { for (name, provider) in rules.providers {
loaded.insert(("rule-providers", name.into()), epoch_nanos(&provider.updated_at)); loaded.insert(("rule-providers", name), epoch_nanos(&provider.updated_at));
} }
for (name, provider) in proxies.providers { for (name, provider) in proxies.providers {
loaded.insert( loaded.insert(
("proxy-providers", name.into()), ("proxy-providers", name),
provider.updated_at.as_deref().and_then(epoch_nanos), provider.updated_at.as_deref().and_then(epoch_nanos),
); );
} }
@@ -1594,7 +1656,7 @@ const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> O
fn mark_service_unavailable_after_owner_loss<E: RunStateEnv>(store: &RunStateStore<E>, reason: OwnerRecoveryReason) { fn mark_service_unavailable_after_owner_loss<E: RunStateEnv>(store: &RunStateStore<E>, reason: OwnerRecoveryReason) {
if matches!(reason, OwnerRecoveryReason::TransportFailure) { if matches!(reason, OwnerRecoveryReason::TransportFailure) {
store.observe(ServiceHealth::Unavailable( store.observe(ServiceHealth::Unavailable(
"service control IPC unavailable after sustained transport failure".into(), "service control IPC unavailable after sustained transport failure".to_owned(),
)); ));
} }
} }
@@ -1855,7 +1917,7 @@ async fn wait_for_service_ipc() -> Result<()> {
} }
Err(ReadyWaitError::Unreachable(error)) => { Err(ReadyWaitError::Unreachable(error)) => {
tracing::Span::current().record("outcome", "unreachable"); tracing::Span::current().record("outcome", "unreachable");
RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}").into())); RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}")));
Err(error).context(CONTEXT) Err(error).context(CONTEXT)
} }
Err(ReadyWaitError::Rejected(error)) => { Err(ReadyWaitError::Rejected(error)) => {
@@ -2670,7 +2732,7 @@ mod tests {
let outcome = super::run_action_restoring_sidecar(&store, was_allowed, async { let outcome = super::run_action_restoring_sidecar(&store, was_allowed, async {
store.perform(PendingAction::Install).await?; store.perform(PendingAction::Install).await?;
// Simulate `wait_for_service_ipc` recording health before it fails. // Simulate `wait_for_service_ipc` recording health before it fails.
store.observe(ServiceHealth::Unavailable("service never answered".into())); store.observe(ServiceHealth::Unavailable("service never answered".to_owned()));
bail!("service IPC did not become available") bail!("service IPC did not become available")
}) })
.await; .await;
+22 -17
View File
@@ -8,6 +8,7 @@ use anyhow::Result;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::RwLock; use parking_lot::RwLock;
use scopeguard::defer; use scopeguard::defer;
use smartstring::alias::String;
use std::{ use std::{
sync::{ sync::{
Arc, Arc,
@@ -17,7 +18,6 @@ use std::{
}; };
use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy}; use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tristr::alias::String;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProxyApplyStep { enum ProxyApplyStep {
@@ -241,20 +241,19 @@ async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState
} }
pub(crate) struct Sysopt { pub(crate) struct Sysopt {
update_lock: TokioMutex<()>, // Owns the proxy target pair; every reader and writer goes through this lock.
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
guard_operation_lock: TokioMutex<()>, guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool, reset_sysproxy: AtomicBool,
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
guard: Arc<RwLock<GuardMonitor>>, guard: Arc<RwLock<GuardMonitor>>,
} }
impl Default for Sysopt { impl Default for Sysopt {
fn default() -> Self { fn default() -> Self {
Self { Self {
update_lock: TokioMutex::new(()), proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
guard_operation_lock: TokioMutex::new(()), guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false), reset_sysproxy: AtomicBool::new(false),
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))), guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
} }
} }
@@ -269,10 +268,9 @@ static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
static BYPASS_SEPARATOR: &str = ","; static BYPASS_SEPARATOR: &str = ",";
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
static DEFAULT_BYPASS: &str = static DEFAULT_BYPASS: &str = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
fn format_bypass(use_default: bool, custom_bypass: &str) -> String { pub(super) fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
if custom_bypass.is_empty() { if custom_bypass.is_empty() {
DEFAULT_BYPASS.into() DEFAULT_BYPASS.into()
} else if use_default { } else if use_default {
@@ -361,7 +359,7 @@ impl Sysopt {
} }
} }
while self.access_guard().read().get_state().is_pendding() { while self.access_guard().read().get_state().is_pendding() {
tokio::task::yield_now().await; tokio::time::sleep(Duration::from_millis(10)).await;
} }
// `start()` false may also mean a guard was already running. // `start()` false may also mean a guard was already running.
!self.access_guard().read().get_state().is_stopped() !self.access_guard().read().get_state().is_stopped()
@@ -409,18 +407,21 @@ impl Sysopt {
/// configuration instead of a partially-applied one (e.g. SOCKS already /// configuration instead of a partially-applied one (e.g. SOCKS already
/// disabled but HTTP still enabled mid-transition). /// disabled but HTTP still enabled mid-transition).
pub(crate) async fn wait_idle(&self) { pub(crate) async fn wait_idle(&self) {
let _ = self.update_lock.lock().await; let _ = self.proxy_state_lock.lock().await;
} }
/// init the sysproxy /// init the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
)]
pub(super) async fn update_sysproxy(&self) -> Result<()> { pub(super) async fn update_sysproxy(&self) -> Result<()> {
let _lock = self.update_lock.lock().await; let mut proxy_state = self.proxy_state_lock.lock().await;
let verge = Config::verge().await.latest_arc(); let verge = Config::verge().await.latest_arc();
// Configured, not live: this runs while the Core is being started or restarted, and // Configured, not live: this runs while the Core is being started or restarted, and
// asking a Core that is not up yet would only fall back here anyway. // asking a Core that is not up yet would only fall back here anyway.
let port = MixedPort::desired().await; let port = MixedPort::desired().await;
let pac_port = server::embedded_server_port()?; let pac_port = server::embedded_server_port()?;
// 先 await, 避免持有锁导致的 Send 问题
let bypass = get_bypass().await; let bypass = get_bypass().await;
let (sys_enable, pac_enable, proxy_host, proxy_guard) = ( let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
@@ -431,7 +432,7 @@ impl Sysopt {
); );
let (sys, auto, guard_type) = { let (sys, auto, guard_type) = {
let (sys, auto) = &mut *self.inner_proxy.write(); let (sys, auto) = &mut *proxy_state;
sys.host = proxy_host.into(); sys.host = proxy_host.into();
sys.port = port; sys.port = port;
sys.bypass = bypass.into(); sys.bypass = bypass.into();
@@ -528,6 +529,10 @@ impl Sysopt {
} }
/// reset the sysproxy /// reset the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
)]
pub(super) async fn reset_sysproxy(&self) -> Result<()> { pub(super) async fn reset_sysproxy(&self) -> Result<()> {
if self if self
.reset_sysproxy .reset_sysproxy
@@ -539,13 +544,13 @@ impl Sysopt {
defer! { defer! {
self.reset_sysproxy.store(false, Ordering::SeqCst); self.reset_sysproxy.store(false, Ordering::SeqCst);
} }
let _lock = self.update_lock.lock().await; let mut proxy_state = self.proxy_state_lock.lock().await;
let _guard_operation = self.guard_operation_lock.lock().await; let _guard_operation = self.guard_operation_lock.lock().await;
let drained = self.stop_proxy_guard_locked().await; let drained = self.stop_proxy_guard_locked().await;
// 直接关闭所有代理 // 直接关闭所有代理
let (sys, auto) = { let (sys, auto) = {
let (sys, auto) = &mut *self.inner_proxy.write(); let (sys, auto) = &mut *proxy_state;
sys.enable = false; sys.enable = false;
auto.enable = false; auto.enable = false;
(sys.clone(), auto.clone()) (sys.clone(), auto.clone())
@@ -677,8 +682,8 @@ mod tests {
(calls.into_inner(), result.is_ok()) (calls.into_inner(), result.is_ok())
} }
fn failure_text(result: anyhow::Result<()>) -> String { fn failure_text(result: anyhow::Result<()>) -> std::string::String {
result.map_or_else(|error| format!("{error:#}").into(), |()| String::from("no failure")) result.map_or_else(|error| format!("{error:#}"), |()| "no failure".to_owned())
} }
#[test] #[test]
+2 -2
View File
@@ -8,6 +8,7 @@ use crate::{
use anyhow::Result; use anyhow::Result;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use smartstring::alias::String;
use std::{ use std::{
collections::HashMap, collections::HashMap,
sync::{ sync::{
@@ -22,7 +23,6 @@ use tokio::{
}; };
use tokio_stream::StreamExt as _; use tokio_stream::StreamExt as _;
use tokio_util::time::{DelayQueue, delay_queue::Key}; use tokio_util::time::{DelayQueue, delay_queue::Key};
use tristr::alias::String;
enum TimerCommand { enum TimerCommand {
Apply(HashMap<String, TaskSchedule>), Apply(HashMap<String, TaskSchedule>),
@@ -502,6 +502,7 @@ mod tests {
use super::{TaskSchedule, Timer, run_timer_profile_update_transition}; use super::{TaskSchedule, Timer, run_timer_profile_update_transition};
use crate::config::{PrfItem, PrfOption}; use crate::config::{PrfItem, PrfOption};
use parking_lot::Mutex; use parking_lot::Mutex;
use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
use std::{ use std::{
sync::{ sync::{
@@ -514,7 +515,6 @@ mod tests {
use tokio::sync::Barrier; use tokio::sync::Barrier;
use tokio_stream::StreamExt as _; use tokio_stream::StreamExt as _;
use tokio_util::time::DelayQueue; use tokio_util::time::DelayQueue;
use tristr::alias::String;
struct CancellationProbe { struct CancellationProbe {
cancelled: Arc<AtomicBool>, cancelled: Arc<AtomicBool>,
+3 -2
View File
@@ -19,6 +19,7 @@ use tokio::fs;
use super::handle; use super::handle;
use anyhow::Result; use anyhow::Result;
use smartstring::alias::String;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
@@ -26,7 +27,6 @@ use tauri::{
AppHandle, Wry, AppHandle, Wry,
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
}; };
use tristr::alias::String;
mod menu_def; mod menu_def;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -636,7 +636,8 @@ async fn create_tray_menu(
.tray_proxy_groups_display_mode .tray_proxy_groups_display_mode
.as_deref() .as_deref()
.unwrap_or("default"); .unwrap_or("default");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; let include_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
let proxy_group_order_map = runtime_proxy_groups_order; let proxy_group_order_map = runtime_proxy_groups_order;
+30 -57
View File
@@ -11,7 +11,6 @@ use std::{
}; };
use tauri_plugin_updater::{Update, UpdaterExt as _}; use tauri_plugin_updater::{Update, UpdaterExt as _};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tristr::alias::String;
pub struct SilentUpdater { pub struct SilentUpdater {
update_ready: AtomicBool, update_ready: AtomicBool,
@@ -19,6 +18,14 @@ pub struct SilentUpdater {
background_cancel: Mutex<Option<CancellationToken>>, background_cancel: Mutex<Option<CancellationToken>>,
} }
#[derive(Serialize, specta::Type)]
#[serde(rename_all = "snake_case")]
pub enum InstallUpdateOutcome {
Installed,
Cancelled,
UpdateChanged,
}
singleton!(SilentUpdater, SILENT_UPDATER); singleton!(SilentUpdater, SILENT_UPDATER);
impl SilentUpdater { impl SilentUpdater {
@@ -35,7 +42,7 @@ impl SilentUpdater {
} }
} }
#[derive(Serialize, Deserialize, specta::Type)] #[derive(Serialize, Deserialize)]
struct UpdateCacheMeta { struct UpdateCacheMeta {
version: String, version: String,
downloaded_at: String, downloaded_at: String,
@@ -56,8 +63,8 @@ impl SilentUpdater {
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?; .with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
let meta = UpdateCacheMeta { let meta = UpdateCacheMeta {
version: version.into(), version: version.to_string(),
downloaded_at: Utc::now().to_rfc3339().into(), downloaded_at: Utc::now().to_rfc3339(),
}; };
let meta_path = cache_dir.join("pending_update.json"); let meta_path = cache_dir.join("pending_update.json");
std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?) std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)
@@ -102,11 +109,11 @@ impl SilentUpdater {
} }
/// Returns the cached installer for `update`, deleting a cache that does not match it. /// Returns the cached installer for `update`, deleting a cache that does not match it.
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> { fn verified_cache(update: &Update) -> Option<Vec<u8>> {
let meta = Self::read_cache_meta().ok()?; let meta = Self::read_cache_meta().ok()?;
let bytes = if meta.version == update.version { let bytes = if meta.version == update.version {
Self::read_cache_bytes().and_then(|bytes| { Self::read_cache_bytes().and_then(|bytes| {
verify_signature(app_handle, &bytes, &update.signature, &update.version)?; update.verify(&bytes)?;
Ok(bytes) Ok(bytes)
}) })
} else { } else {
@@ -128,45 +135,6 @@ impl SilentUpdater {
} }
} }
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
/// with the same rules `Update::download` applies.
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
use base64::Engine as _;
let decode = |value: &str| -> Result<String> {
// from_utf8 exists only on the std String type; the tristr alias shadows the bare name.
Ok(std::string::String::from_utf8(base64::engine::general_purpose::STANDARD.decode(value)?)?.into())
};
let config: tauri_plugin_updater::Config = serde_json::from_value(
app_handle
.config()
.plugins
.0
.get("updater")
.cloned()
.context("updater is not configured")?,
)?;
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
public_key
.verify(bytes, &signature, true)
.context("signature mismatch")?;
match signature
.trusted_comment()
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
{
Some(signed) => anyhow::ensure!(
signed.trim_start_matches('v') == version,
"signed for v{signed}, not v{version}"
),
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
}
Ok(())
}
pub fn is_build_to_stable(current: &str, remote: &str) -> bool { pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else { let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false; return false;
@@ -391,7 +359,7 @@ impl SilentUpdater {
} }
}; };
let Some(bytes) = Self::verified_cache(app_handle, &update) else { let Some(bytes) = Self::verified_cache(&update) else {
return false; return false;
}; };
@@ -424,13 +392,13 @@ impl SilentUpdater {
} }
/// Installs `version` for the update dialog, reusing the cache when it holds that version; /// Installs `version` for the update dialog, reusing the cache when it holds that version;
/// `false` when cancelled before the installer starts. /// A changed offer must be confirmed again before downloading or installing it.
pub async fn install_update( pub async fn install_update(
&self, &self,
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
version: &str, version: &str,
on_event: impl Fn(DownloadEvent) + Sync, on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<bool> { ) -> Result<InstallUpdateOutcome> {
let cancel = CancellationToken::new(); let cancel = CancellationToken::new();
*self.manual_cancel.lock() = Some(cancel.clone()); *self.manual_cancel.lock() = Some(cancel.clone());
let prepared = tokio::select! { let prepared = tokio::select! {
@@ -440,31 +408,36 @@ impl SilentUpdater {
// `cancel_download` cancels under the lock, so once the token is taken back its state is // `cancel_download` cancels under the lock, so once the token is taken back its state is
// final; a cancel during the last synchronous poll of `prepare_install` still wins. // final; a cancel during the last synchronous poll of `prepare_install` still wins.
self.manual_cancel.lock().take(); self.manual_cancel.lock().take();
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else { let Some(prepared) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
logging!(info, Type::System, "Update v{version} cancelled"); logging!(info, Type::System, "Update v{version} cancelled");
return Ok(false); return Ok(InstallUpdateOutcome::Cancelled);
};
let Some((update, bytes)) = prepared else {
return Ok(InstallUpdateOutcome::UpdateChanged);
}; };
logging!(info, Type::System, "Installing update v{version}..."); logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?; Self::install(update, bytes).await?;
Self::delete_cache(); Self::delete_cache();
Ok(true) Ok(InstallUpdateOutcome::Installed)
} }
async fn prepare_install( async fn prepare_install(
app_handle: &tauri::AppHandle, app_handle: &tauri::AppHandle,
version: &str, version: &str,
on_event: impl Fn(DownloadEvent) + Sync, on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<(Update, Vec<u8>)> { ) -> Result<Option<(Update, Vec<u8>)>> {
let update = Self::check(app_handle) let Some(update) = Self::check(app_handle)
.await? .await?
.filter(|update| update.version == version) .filter(|update| update.version == version)
.with_context(|| format!("v{version} is no longer offered"))?; else {
let bytes = match Self::verified_cache(app_handle, &update) { return Ok(None);
};
let bytes = match Self::verified_cache(&update) {
Some(bytes) => bytes, Some(bytes) => bytes,
None => download(&update, on_event).await?, None => download(&update, on_event).await?,
}; };
Ok((update, bytes)) Ok(Some((update, bytes)))
} }
pub fn cancel_download(&self) { pub fn cancel_download(&self) {
@@ -641,7 +614,7 @@ impl SilentUpdater {
return Ok(()); return Ok(());
} }
if Self::verified_cache(app_handle, &update).is_some() { if Self::verified_cache(&update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached"); logging!(info, Type::System, "Silent updater: v{version} already cached");
} else { } else {
logging!(info, Type::System, "Silent updater: downloading v{version}..."); logging!(info, Type::System, "Silent updater: downloading v{version}...");
+12 -3
View File
@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use scopeguard::defer; use scopeguard::defer;
use serde::Serialize; use serde::Serialize;
use smartstring::alias::String;
use std::{ use std::{
fmt, fmt,
hash::{DefaultHasher, Hash as _, Hasher as _}, hash::{DefaultHasher, Hash as _, Hasher as _},
@@ -9,7 +10,6 @@ use std::{
}; };
use tauri_plugin_shell::ShellExt as _; use tauri_plugin_shell::ShellExt as _;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
use crate::config::Config; use crate::config::Config;
use crate::core::handle; use crate::core::handle;
@@ -114,8 +114,14 @@ impl fmt::Display for ValidationSkipReason {
#[serde(tag = "status", rename_all = "camelCase")] #[serde(tag = "status", rename_all = "camelCase")]
pub enum ValidationOutcome { pub enum ValidationOutcome {
Valid, Valid,
Invalid { kind: ValidationErrorKind, message: String }, Invalid {
Skipped { reason: ValidationSkipReason }, kind: ValidationErrorKind,
#[specta(type = std::string::String)]
message: String,
},
Skipped {
reason: ValidationSkipReason,
},
Busy, Busy,
} }
@@ -318,6 +324,7 @@ impl CoreConfigValidator {
return Self::validate_file_syntax_outcome(config_path).await; return Self::validate_file_syntax_outcome(config_path).await;
} }
crate::core::service::sync_runtime_geo_by_service().await;
let is_script = match Self::is_script_file(config_path).await { let is_script = match Self::is_script_file(config_path).await {
Ok(result) => result, Ok(result) => result,
Err(err) => { Err(err) => {
@@ -419,6 +426,8 @@ impl CoreConfigValidator {
} }
logging!(info, Type::Validate, "生成临时配置文件用于验证"); logging!(info, Type::Validate, "生成临时配置文件用于验证");
// The test run reads the app dir, which never sees updates made by a service core.
crate::core::service::sync_runtime_geo_by_service().await;
let fingerprint = validation_fingerprint(yaml).await; let fingerprint = validation_fingerprint(yaml).await;
if *LAST_VALIDATED.lock() == Some(fingerprint) { if *LAST_VALIDATED.lock() == Some(fingerprint) {
logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查"); logging!(info, Type::Validate, "配置与上次通过验证的字节一致,跳过内核检查");
+2 -11
View File
@@ -1,10 +1,7 @@
#![cfg(target_os = "windows")] #![cfg(target_os = "windows")]
use crate::utils::dirs; use crate::{core::service, utils::dirs};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand;
use std::process::Command as StdCommand;
pub fn invoke_uwptools() -> Result<()> { pub fn invoke_uwptools() -> Result<()> {
let resource_dir = dirs::app_resources_dir()?; let resource_dir = dirs::app_resources_dir()?;
@@ -14,13 +11,7 @@ pub fn invoke_uwptools() -> Result<()> {
bail!("enableLoopback exe not found"); bail!("enableLoopback exe not found");
} }
let token = Token::with_current_process()?; service::run_maybe_elevated(&tool_path, false)?;
let level = token.privilege_level()?;
match level {
PrivilegeLevel::NotPrivileged => RunasCommand::new(tool_path).status()?,
_ => StdCommand::new(tool_path).status()?,
};
Ok(()) Ok(())
} }
@@ -1,8 +0,0 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (config.mode === 'script') {
config.mode = 'rule'
}
return config
}
@@ -1,12 +0,0 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => {
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]
}
})
}
return config
}
+8 -68
View File
@@ -4,8 +4,8 @@ use crate::{
utils::{dirs, help}, utils::{dirs, help},
}; };
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ChainItem { pub struct ChainItem {
@@ -22,12 +22,6 @@ pub enum ChainType {
Groups(SeqMap), Groups(SeqMap),
} }
#[derive(Debug, Clone)]
pub enum ChainSupport {
ClashMeta,
ClashMetaAlpha,
}
// Helper trait to allow async conversion // Helper trait to allow async conversion
pub trait AsyncChainItemFrom { pub trait AsyncChainItemFrom {
async fn from_async(item: &PrfItem) -> Option<ChainItem>; async fn from_async(item: &PrfItem) -> Option<ChainItem>;
@@ -53,70 +47,16 @@ impl AsyncChainItemFrom for Option<ChainItem> {
uid, uid,
data: ChainType::Merge(help::read_mapping(&path).await.ok()?), data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}), }),
"rules" => { "rules" | "proxies" | "groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?; let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem { let data = match itype {
uid, "rules" => ChainType::Rules(seq_map),
data: ChainType::Rules(seq_map), "proxies" => ChainType::Proxies(seq_map),
}) _ => ChainType::Groups(seq_map),
} };
"proxies" => { Some(ChainItem { uid, data })
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Proxies(seq_map),
})
}
"groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
Some(ChainItem {
uid,
data: ChainType::Groups(seq_map),
})
} }
_ => None, _ => None,
} }
} }
} }
impl ChainItem {
/// 内建支持一些脚本
pub fn builtin() -> Vec<(ChainSupport, Self)> {
// meta 的一些处理
let meta_guard = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
// meta 的一些处理
let meta_guard_alpha = Self::to_script("verge_meta_guard", include_str!("./builtin/meta_guard.js"));
// meta 1.13.2 alpn string 转 数组
let hy_alpn_alpha = Self::to_script("verge_hy_alpn", include_str!("./builtin/meta_hy_alpn.js"));
vec![
(ChainSupport::ClashMeta, hy_alpn),
(ChainSupport::ClashMeta, meta_guard),
(ChainSupport::ClashMetaAlpha, hy_alpn_alpha),
(ChainSupport::ClashMetaAlpha, meta_guard_alpha),
]
}
pub fn to_script<U: Into<String>, D: Into<String>>(uid: U, data: D) -> Self {
Self {
uid: uid.into(),
data: ChainType::Script(data.into()),
}
}
}
impl ChainSupport {
pub fn is_support(&self, core: Option<&String>) -> bool {
match core {
Some(core) => matches!(
(self, core.as_str()),
(Self::ClashMeta, "verge-mihomo") | (Self::ClashMetaAlpha, "verge-mihomo-alpha")
),
None => true,
}
}
}
+2 -13
View File
@@ -1,5 +1,5 @@
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use tristr::alias::String; use smartstring::alias::String;
pub const HANDLE_FIELDS: [&str; 12] = [ pub const HANDLE_FIELDS: [&str; 12] = [
"mode", "mode",
@@ -27,7 +27,7 @@ fn lowercase_key(key: &str) -> Value {
pub fn use_lowercase(config: &Mapping) -> Mapping { pub fn use_lowercase(config: &Mapping) -> Mapping {
let mut lowercased = Mapping::new(); let mut lowercased = Mapping::new();
for (key, value) in config.into_iter() { for (key, value) in config.iter() {
if let Some(key_str) = key.as_str() { if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value.clone()); lowercased.insert(lowercase_key(key_str), value.clone());
} }
@@ -35,17 +35,6 @@ pub fn use_lowercase(config: &Mapping) -> Mapping {
lowercased lowercased
} }
pub fn use_lowercase_owned(config: Mapping) -> Mapping {
let mut lowercased = Mapping::new();
for (key, value) in config {
if let Some(key_str) = key.as_str() {
lowercased.insert(lowercase_key(key_str), value);
}
}
lowercased
}
pub fn use_sort(mut config: Mapping) -> Mapping { pub fn use_sort(mut config: Mapping) -> Mapping {
let mut sorted = Mapping::new(); let mut sorted = Mapping::new();
HANDLE_FIELDS.into_iter().for_each(|key| { HANDLE_FIELDS.into_iter().for_each(|key| {
+65 -128
View File
@@ -8,7 +8,7 @@ mod tun;
use self::{ use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType}, chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase_owned, use_sort}, field::{use_keys, use_lowercase, use_sort},
merge::use_merge, merge::use_merge,
script::use_script, script::use_script,
seq::{SeqMap, use_seq}, seq::{SeqMap, use_seq},
@@ -26,15 +26,14 @@ use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use tokio::fs; use tokio::fs;
use tristr::alias::String;
type ResultLog = Vec<(String, String)>; type ResultLog = Vec<(String, String)>;
#[derive(Debug)] #[derive(Debug)]
struct ConfigValues { struct ConfigValues {
clash_config: Mapping, clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool, enable_tun: bool,
enable_builtin: bool, enable_builtin: bool,
socks_enabled: bool, socks_enabled: bool,
@@ -128,8 +127,7 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
let dns_settings = verge_arc.dns_settings_for(profile_uid); let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation; let dns_override_confirmation = dns_settings.confirmation;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( let (enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false), enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true), enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false), verge_socks_enabled.unwrap_or(false),
@@ -148,7 +146,6 @@ async fn get_config_values(profile_uid: &str) -> ConfigValues {
ConfigValues { ConfigValues {
clash_config, clash_config,
clash_core,
enable_tun, enable_tun,
enable_builtin, enable_builtin,
socks_enabled, socks_enabled,
@@ -199,35 +196,25 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let name = current_item.name.clone().unwrap_or_default(); let name = current_item.name.clone().unwrap_or_default();
let ProfileItems {
merge_item: default_merge,
script_item: default_script,
rules_item: default_rules,
proxies_item: default_proxies,
groups_item: default_groups,
global_merge: default_global_merge,
global_script: default_global_script,
..
} = ProfileItems::default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!( let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem { chain_item_or_default(profiles.get_item(&merge_uid).ok(), || default_merge,),
uid: "".into(), chain_item_or_default(profiles.get_item(&script_uid).ok(), || default_script,),
data: ChainType::Merge(Mapping::new()), chain_item_or_default(profiles.get_item(&rules_uid).ok(), || default_rules,),
},), chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || default_proxies,),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem { chain_item_or_default(profiles.get_item(&groups_uid).ok(), || default_groups,),
uid: "".into(), chain_item_or_default(profiles.get_item("Merge").ok(), || default_global_merge,),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()), chain_item_or_default(profiles.get_item("Script").ok(), || default_global_script,),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
); );
Ok(ProfileItems { Ok(ProfileItems {
@@ -243,16 +230,16 @@ async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
}) })
} }
async fn process_global_items( async fn process_override_items(
mut config: Mapping, mut config: Mapping,
mut exists_keys: Vec<String>, mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>, mut result_map: HashMap<String, ResultLog>,
global_merge: ChainItem, merge_item: ChainItem,
global_script: ChainItem, script_item: ChainItem,
profile_name: &String, profile_name: &String,
authoritative: &AuthoritativeFields, authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) { ) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data { if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge)); exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config); let before = authoritative.current(&config);
config = use_merge(&merge, config); config = use_merge(&merge, config);
@@ -262,11 +249,11 @@ async fn process_global_items(
.map(discarded_note) .map(discarded_note)
.collect(); .collect();
if !notes.is_empty() { if !notes.is_empty() {
result_map.entry(global_merge.uid).or_default().extend(notes); result_map.entry(merge_item.uid).or_default().extend(notes);
} }
} }
if let ChainType::Script(script) = global_script.data { if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config); let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys); exists_keys.extend(changed_keys);
@@ -277,7 +264,7 @@ async fn process_global_items(
.into_iter() .into_iter()
.map(discarded_note), .map(discarded_note),
); );
result_map.insert(global_script.uid, logs); result_map.insert(script_item.uid, logs);
} }
(config, exists_keys, result_map) (config, exists_keys, result_map)
@@ -544,46 +531,6 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
config config
} }
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure. /// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config( fn merge_default_config(
mut config: Mapping, mut config: Mapping,
@@ -655,25 +602,12 @@ fn merge_default_config(
config config
} }
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping { fn apply_builtin_scripts(mut config: Mapping, enable_builtin: bool) -> Mapping {
if enable_builtin { if enable_builtin {
let items: Vec<_> = ChainItem::builtin() // The JS path saw a lowercased view; lowercase once, only when the builtin runs.
.into_iter() config = use_lowercase(&config);
.filter(|(s, _)| s.is_support(clash_core.as_ref())) logging!(debug, Type::Core, "run builtin script verge_hy_alpn");
.map(|(_, c)| c) config = builtin_hy_alpn(config);
.collect();
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
} }
config config
@@ -693,14 +627,6 @@ fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
config config
} }
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping { fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in // built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"]; const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
@@ -879,7 +805,6 @@ pub async fn enhance(
let cfg_vals = get_config_values(profile_uid).await; let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues { let ConfigValues {
clash_config, clash_config,
clash_core,
enable_tun, enable_tun,
enable_builtin, enable_builtin,
socks_enabled, socks_enabled,
@@ -929,13 +854,13 @@ pub async fn enhance(
tproxy_enabled, tproxy_enabled,
); );
let config = apply_builtin_scripts(config, clash_core, enable_builtin); let config = apply_builtin_scripts(config, enable_builtin);
let config = use_tun(config, enable_tun); let config = use_tun(config, enable_tun);
let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await; let (config, dns_settings) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings); let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_global_items( let (config, exists_keys, result_map) = process_override_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
@@ -946,7 +871,7 @@ pub async fn enhance(
) )
.await; .await;
let (config, exists_keys, result_map) = process_profile_items( let (config, exists_keys, result_map) = process_override_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
@@ -1428,7 +1353,7 @@ mod authoritative_field_tests {
mod tests { mod tests {
use super::{ use super::{
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address, AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_global_items, process_profile_items, use_keys, process_override_items, use_keys,
}; };
use std::collections::HashMap; use std::collections::HashMap;
@@ -1476,22 +1401,28 @@ mod tests {
data: ChainType::Merge(merge.clone()), data: ChainType::Merge(merge.clone()),
}; };
let profile_name = "test-profile".into(); let profile_name = "test-profile".into();
let (config, keys, logs) = process_global_items( let (config, keys, logs) = process_override_items(
config, config,
vec![], vec![],
HashMap::new(), HashMap::new(),
merge_item("global-merge"), merge_item("global-merge"),
ChainItem::to_script("global-script", script), ChainItem {
uid: "global-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name, &profile_name,
&authoritative, &authoritative,
) )
.await; .await;
let (config, _, logs) = process_profile_items( let (config, _, logs) = process_override_items(
config, config,
keys, keys,
logs, logs,
merge_item("profile-merge"), merge_item("profile-merge"),
ChainItem::to_script("profile-script", script), ChainItem {
uid: "profile-script".into(),
data: ChainType::Script(script.into()),
},
&profile_name, &profile_name,
&authoritative, &authoritative,
) )
@@ -1536,16 +1467,19 @@ mod tests {
nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}", nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}",
)), )),
}; };
let global_script = ChainItem::to_script( let global_script = ChainItem {
"Script", uid: "Script".into(),
r#"function main(config) { data: ChainType::Script(
r#"function main(config) {
config["global-script-wins"] = "global-script"; config["global-script-wins"] = "global-script";
config["profile-merge-wins"] = "global-script"; config["profile-merge-wins"] = "global-script";
config["profile-script-wins"] = "global-script"; config["profile-script-wins"] = "global-script";
config.nested.winner = "global-script"; config.nested.winner = "global-script";
return config; return config;
}"#, }"#
); .into(),
),
};
let profile_merge = ChainItem { let profile_merge = ChainItem {
uid: "profile-merge".into(), uid: "profile-merge".into(),
data: ChainType::Merge(mapping( data: ChainType::Merge(mapping(
@@ -1553,18 +1487,21 @@ mod tests {
nested: {winner: profile-merge}}", nested: {winner: profile-merge}}",
)), )),
}; };
let profile_script = ChainItem::to_script( let profile_script = ChainItem {
"profile-script", uid: "profile-script".into(),
r#"function main(config) { data: ChainType::Script(
r#"function main(config) {
config["profile-script-wins"] = "profile-script"; config["profile-script-wins"] = "profile-script";
config.nested.winner = "profile-script"; config.nested.winner = "profile-script";
return config; return config;
}"#, }"#
); .into(),
),
};
let profile_name = "test-profile".into(); let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new()); let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_global_items( let (config, exists_keys, result_map) = process_override_items(
config, config,
exists_keys, exists_keys,
HashMap::new(), HashMap::new(),
@@ -1574,7 +1511,7 @@ mod tests {
&authoritative, &authoritative,
) )
.await; .await;
let (config, exists_keys, _) = process_profile_items( let (config, exists_keys, _) = process_override_items(
config, config,
exists_keys, exists_keys,
result_map, result_map,
+16 -16
View File
@@ -1,13 +1,13 @@
use crate::{process::AsyncHandler, utils::tmpl}; use crate::{process::AsyncHandler, utils::tmpl};
use super::field::{use_lowercase, use_lowercase_owned}; use super::field::use_lowercase;
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute}; use boa_engine::{Context, JsString, JsValue, Source, js_string, native_function::NativeFunction, property::Attribute};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::sync::Arc; use std::sync::Arc;
use tristr::alias::String;
const MAX_OUTPUTS: usize = 1000; const MAX_OUTPUTS: usize = 1000;
const MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1MB const MAX_OUTPUT_SIZE: usize = 1024 * 1024; // 1MB
@@ -16,13 +16,13 @@ const MAX_LOOP_ITERATIONS: u64 = 10_000_000;
const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
type ScriptResult = (Mapping, Vec<String>, Vec<(String, String)>); type ScriptResult = (Mapping, Vec<String>, Vec<(String, String)>);
type EvalOutcome = (Result<Mapping, String>, Vec<(String, String)>); type EvalOutcome = (Result<Mapping, std::string::String>, Vec<(String, String)>);
/// Never fails: exceptions are logged and the input config is returned unchanged. /// Never fails: exceptions are logged and the input config is returned unchanged.
pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult { pub(super) async fn use_script(script: String, config: Mapping, name: String) -> ScriptResult {
// The template is identity-plus-lowercasing; skip the Boa round-trip. // The template is identity-plus-lowercasing; skip the Boa round-trip.
if script == tmpl::ITEM_SCRIPT { if script == tmpl::ITEM_SCRIPT {
return (use_lowercase_owned(config), vec![], vec![]); return (use_lowercase(&config), vec![], vec![]);
} }
let json = match serde_json::to_string(&use_lowercase(&config)) { let json = match serde_json::to_string(&use_lowercase(&config)) {
@@ -37,9 +37,9 @@ pub(super) async fn use_script(script: String, config: Mapping, name: String) ->
let evaluated = AsyncHandler::spawn_blocking(move || eval_script(&script, &json, &name)); let evaluated = AsyncHandler::spawn_blocking(move || eval_script(&script, &json, &name));
let (outcome, mut logs) = match tokio::time::timeout(SCRIPT_TIMEOUT, evaluated).await { let (outcome, mut logs) = match tokio::time::timeout(SCRIPT_TIMEOUT, evaluated).await {
Ok(Ok(evaluated)) => evaluated, Ok(Ok(evaluated)) => evaluated,
Ok(Err(join_err)) => (Err(format!("script task panicked: {join_err}").into()), vec![]), Ok(Err(join_err)) => (Err(format!("script task panicked: {join_err}")), vec![]),
Err(_elapsed) => ( Err(_elapsed) => (
Err(format!("script execution timed out after {SCRIPT_TIMEOUT:?}").into()), Err(format!("script execution timed out after {SCRIPT_TIMEOUT:?}")),
vec![], vec![],
), ),
}; };
@@ -59,7 +59,7 @@ pub(super) async fn use_script(script: String, config: Mapping, name: String) ->
(result, changed_keys, logs) (result, changed_keys, logs)
} }
Err(reason) => { Err(reason) => {
logs.push(("exception".into(), reason)); logs.push(("exception".into(), reason.into()));
(config, vec![], logs) (config, vec![], logs)
} }
} }
@@ -79,7 +79,7 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
let outputs_clone = Arc::clone(&outputs); let outputs_clone = Arc::clone(&outputs);
let total_size_clone = Arc::clone(&total_size); let total_size_clone = Arc::clone(&total_size);
let bail = |reason: String| (Err(reason), outputs.lock().to_vec()); let bail = |reason: std::string::String| (Err(reason), outputs.lock().to_vec());
let _ = context.register_global_builtin_callable("__verge_log__".into(), 2, unsafe { let _ = context.register_global_builtin_callable("__verge_log__".into(), 2, unsafe {
NativeFunction::from_closure(move |_: &JsValue, args: &[JsValue], context: &mut Context| { NativeFunction::from_closure(move |_: &JsValue, args: &[JsValue], context: &mut Context| {
@@ -137,13 +137,13 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
JsValue::from(JsString::from(config_json)), JsValue::from(JsString::from(config_json)),
Attribute::all(), Attribute::all(),
) { ) {
return bail(format!("failed to bind config for script: {err}").into()); return bail(format!("failed to bind config for script: {err}"));
} }
// 仅处理 name 参数中的特殊字符 // 仅处理 name 参数中的特殊字符
let safe_name = escape_js_string_for_single_quote(name); let safe_name = escape_js_string_for_single_quote(name);
if safe_name.len() > 1024 { if safe_name.len() > 1024 {
return bail("Name parameter too long".into()); return bail("Name parameter too long".to_owned());
} }
let code = format!( let code = format!(
@@ -157,30 +157,30 @@ fn eval_script(script: &str, config_json: &str, name: &String) -> EvalOutcome {
let result = match context.eval(Source::from_bytes(code.as_str())) { let result = match context.eval(Source::from_bytes(code.as_str())) {
Ok(result) if result.is_string() => result, Ok(result) if result.is_string() => result,
_ => return bail("main function should return object".into()), _ => return bail("main function should return object".to_owned()),
}; };
let result = match result.to_string(&mut context) { let result = match result.to_string(&mut context) {
Ok(result) => result, Ok(result) => result,
Err(e) => return bail(format!("Failed to convert JS result to string: {e}").into()), Err(e) => return bail(format!("Failed to convert JS result to string: {e}")),
}; };
let result = match result.to_std_string() { let result = match result.to_std_string() {
Ok(result) => result, Ok(result) => result,
Err(_) => return bail("Failed to convert JS string to std string".into()), Err(_) => return bail("Failed to convert JS string to std string".to_owned()),
}; };
if result.len() > MAX_JSON_SIZE { if result.len() > MAX_JSON_SIZE {
return bail("Script result exceeds maximum allowed size".into()); return bail("Script result exceeds maximum allowed size".to_owned());
} }
match parse_json_safely(&result) { match parse_json_safely(&result) {
Ok(config) => (Ok(use_lowercase_owned(config)), outputs.lock().to_vec()), Ok(config) => (Ok(use_lowercase(&config)), outputs.lock().to_vec()),
Err(err) => { Err(err) => {
logging!( logging!(
error, error,
Type::Config, Type::Config,
"Script execution error: {err:#}. Script name: {name}" "Script execution error: {err:#}. Script name: {name}"
); );
bail("Script execution failed".into()) bail("Script execution failed".to_owned())
} }
} }
} }
+9 -10
View File
@@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Sequence, Value}; use serde_yaml_ng::{Mapping, Sequence, Value};
use std::collections::HashSet; use std::collections::HashSet;
use tristr::alias::String;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeqMap { pub struct SeqMap {
@@ -13,8 +12,8 @@ pub struct SeqMap {
fn collect_proxy_names(seq: &Sequence) -> Vec<String> { fn collect_proxy_names(seq: &Sequence) -> Vec<String> {
seq.iter() seq.iter()
.filter_map(|item| match item { .filter_map(|item| match item {
Value::Mapping(map) => map.get("name").and_then(Value::as_str).map(Into::into), Value::Mapping(map) => map.get("name").and_then(Value::as_str).map(str::to_owned),
Value::String(name) => Some(name.to_owned().into()), Value::String(name) => Some(name.to_owned()),
_ => None, _ => None,
}) })
.collect() .collect()
@@ -59,10 +58,10 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
.into_iter() .into_iter()
.filter(|item| { .filter(|item| {
if let Value::String(s) = item { if let Value::String(s) = item {
!delete.iter().any(|deleted| deleted == s) !delete.contains(s)
} else if let Value::Mapping(m) = item { } else if let Value::Mapping(m) = item {
if let Some(Value::String(name)) = m.get("name") { if let Some(Value::String(name)) = m.get("name") {
!delete.iter().any(|deleted| deleted == name) !delete.contains(name)
} else { } else {
true true
} }
@@ -100,7 +99,7 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
.into_iter() .into_iter()
.filter(|p| { .filter(|p| {
if let Value::String(name) = p { if let Value::String(name) = p {
!delete.iter().any(|deleted| deleted == name) !delete.contains(name)
} else { } else {
true true
} }
@@ -120,12 +119,12 @@ pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
let mut seen_proxy_names = HashSet::new(); let mut seen_proxy_names = HashSet::new();
for name in &added_proxy_names { for name in &added_proxy_names {
if seen_proxy_names.insert(name.clone()) { if seen_proxy_names.insert(name.clone()) {
merged_proxies.push(Value::String(name.clone().into())); merged_proxies.push(Value::String(name.clone()));
} }
} }
for value in base_group_proxies { for value in base_group_proxies {
if let Value::String(name) = &value if let Value::String(name) = &value
&& !seen_proxy_names.insert(name.to_owned().into()) && !seen_proxy_names.insert(name.to_owned())
{ {
continue; continue;
} }
@@ -181,7 +180,7 @@ proxy-groups:
let seq = SeqMap { let seq = SeqMap {
prepend: Sequence::new(), prepend: Sequence::new(),
append: Sequence::new(), append: Sequence::new(),
delete: vec!["proxy1".into()], delete: vec!["proxy1".to_string()],
}; };
config = use_seq(seq, config, "proxies"); config = use_seq(seq, config, "proxies");
@@ -318,7 +317,7 @@ proxy-groups: "invalid"
let seq = SeqMap { let seq = SeqMap {
prepend: Sequence::new(), prepend: Sequence::new(),
append: Sequence::new(), append: Sequence::new(),
delete: vec!["proxy1".into()], delete: vec!["proxy1".to_string()],
}; };
config = use_seq(seq, config, "proxies"); config = use_seq(seq, config, "proxies");
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("fake-ip"); .unwrap_or("fake-ip");
if current_mode == "fake-ip" || !dns_val.contains_key(Value::from("enhanced-mode")) { if current_mode == "fake-ip" {
revise!(dns_val, "enable", true); revise!(dns_val, "enable", true);
revise!(dns_val, "ipv6", ipv6_val); revise!(dns_val, "ipv6", ipv6_val);
+56 -53
View File
@@ -9,17 +9,20 @@ use crate::{
}; };
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use chrono::Utc; use chrono::Utc;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging};
use reqwest_dav::list_cmd::ListFile; use reqwest_dav::list_cmd::ListFile;
use serde::Serialize; use serde::Serialize;
use smartstring::alias::String;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::fs; use tokio::fs;
use tristr::alias::String;
#[derive(Debug, Serialize, specta::Type)] #[derive(Debug, Serialize, specta::Type)]
pub struct LocalBackupFile { pub struct LocalBackupFile {
#[specta(type = std::string::String)]
pub filename: String, pub filename: String,
#[specta(type = std::string::String)]
pub path: String, pub path: String,
#[specta(type = std::string::String)]
pub last_modified: String, pub last_modified: String,
pub content_length: u64, pub content_length: u64,
} }
@@ -30,6 +33,8 @@ async fn finalize_restored_verge_config(
webdav_username: Option<String>, webdav_username: Option<String>,
webdav_password: Option<String>, webdav_password: Option<String>,
) -> Result<()> { ) -> Result<()> {
let config_write = Config::lock_config_write().await;
// A broken restored `verge.yaml` is a restore failure, not a reason to load defaults. // A broken restored `verge.yaml` is a restore failure, not a reason to load defaults.
let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?; let mut restored = help::read_yaml::<IVerge>(&verge_path()?).await?;
restored.webdav_url = webdav_url; restored.webdav_url = webdav_url;
@@ -40,43 +45,42 @@ async fn finalize_restored_verge_config(
let restored_clash = IClashTemp::new().await; let restored_clash = IClashTemp::new().await;
let clash_draft = Config::clash().await; let clash_draft = Config::clash().await;
clash_draft.edit_draft(|d| { clash_draft.edit_draft(|d| {
*d = restored_clash.clone(); *d = restored_clash;
}); });
clash_draft.apply(); clash_draft.apply();
let restored_profiles = IProfiles::new().await; let restored_profiles = IProfiles::new().await;
let profiles_draft = Config::profiles().await; let profiles_draft = Config::profiles().await;
profiles_draft.edit_draft(|d| { profiles_draft.edit_draft(|d| {
*d = restored_profiles.clone(); *d = restored_profiles;
}); });
profiles_draft.apply(); profiles_draft.apply();
let verge_draft = Config::verge().await; let verge_draft = Config::verge().await;
{ verge_draft.edit_draft(|d| {
// Hold the write lock so a concurrent patch cannot stage a draft the proxy write reads. *d = restored.clone();
let _config_write = Config::lock_config_write().await; });
verge_draft.edit_draft(|d| { verge_draft.apply();
*d = restored.clone();
});
verge_draft.apply();
// Turn it off here; a failing core side effect below would otherwise skip this step. // Turn it off here; a failing core side effect below would otherwise skip this step.
if !restored.enable_system_proxy.unwrap_or_default() { if !restored.enable_system_proxy.unwrap_or_default() {
let result = async { let result = async {
let _lifecycle = CoreManager::global().lifecycle_lock.lock().await; let _lifecycle = CoreManager::global().lifecycle_lock.lock().await;
proxy_control::apply().await?; proxy_control::apply().await?;
proxy_control::refresh_guard().await proxy_control::refresh_guard().await
} }
.await; .await;
if let Err(err) = result { if let Err(err) = result {
logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}"); logging!(error, Type::Backup, "Failed to turn the system proxy off: {err:#}");
if SystemProxyStateUnknown::is_in(&err) { if SystemProxyStateUnknown::is_in(&err) {
return Err(err); return Err(err);
}
} }
} }
} }
// `patch_verge` re-acquires the configuration write lock.
drop(config_write);
// Run configuration side effects without rewriting the already-restored file. // Run configuration side effects without rewriting the already-restored file.
if let Err(err) = super::patch_verge(&restored, true).await { if let Err(err) = super::patch_verge(&restored, true).await {
logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}"); logging!(error, Type::Backup, "Failed to apply restored verge config: {err:#}");
@@ -85,7 +89,6 @@ async fn finalize_restored_verge_config(
return Err(err); return Err(err);
} }
} }
logging_error!(Type::Config, Config::sync_dns_override().await);
Ok(()) Ok(())
} }
@@ -131,14 +134,34 @@ pub async fn delete_webdav_backup(filename: String) -> Result<()> {
}) })
} }
/// Backup creation strips the WebDAV credentials, so the live ones are kept.
async fn restore_backup_archive(path: PathBuf) -> Result<()> {
let (webdav_url, webdav_username, webdav_password) = {
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let home_dir = app_home_dir()?;
AsyncHandler::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&path)?;
let mut zip = zip::ZipArchive::new(file)?;
zip.extract(&home_dir)?;
Ok(())
})
.await
.map_err(|join| anyhow!("backup extraction task failed: {join}"))??;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await
}
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename))] #[tracing::instrument(skip_all, level = "info", fields(filename = %filename))]
pub async fn restore_webdav_backup(filename: String) -> Result<()> { pub async fn restore_webdav_backup(filename: String) -> Result<()> {
let verge = Config::verge().await;
let verge_data = verge.latest_arc();
let webdav_url = verge_data.webdav_url.clone();
let webdav_username = verge_data.webdav_username.clone();
let webdav_password = verge_data.webdav_password.clone();
let backup_storage_path = app_home_dir() let backup_storage_path = app_home_dir()
.map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))? .map_err(|e| anyhow::anyhow!("Failed to get app home dir: {e}"))?
.join(filename.as_str()); .join(filename.as_str());
@@ -155,12 +178,7 @@ pub async fn restore_webdav_backup(filename: String) -> Result<()> {
err err
})?; })?;
let value = backup_storage_path.clone(); let res = restore_backup_archive(backup_storage_path.clone()).await;
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&value)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
let res = finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await;
let _ = backup_storage_path.remove_if_exists().await; let _ = backup_storage_path.remove_if_exists().await;
res res
} }
@@ -329,22 +347,7 @@ pub async fn restore_local_backup(filename: String) -> Result<()> {
return Err(anyhow!("Backup file not found: {}", filename)); return Err(anyhow!("Backup file not found: {}", filename));
} }
let (webdav_url, webdav_username, webdav_password) = { restore_backup_archive(target_path).await
let verge = Config::verge().await;
let verge = verge.latest_arc();
(
verge.webdav_url.clone(),
verge.webdav_username.clone(),
verge.webdav_password.clone(),
)
};
let file = AsyncHandler::spawn_blocking(move || std::fs::File::open(&target_path)).await??;
let mut zip = zip::ZipArchive::new(file)?;
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
zip.extract(app_home_dir()?)?;
finalize_restored_verge_config(webdav_url, webdav_username, webdav_password).await?;
Ok(())
} }
#[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))] #[tracing::instrument(skip_all, level = "info", fields(filename = %filename, destination = %destination))]
+12 -46
View File
@@ -3,26 +3,19 @@ use crate::core::notify::{Refresh, announce};
use crate::{ use crate::{
config::{Config, MixedPort}, config::{Config, MixedPort},
core::{CoreManager, handle, tray}, core::{CoreManager, handle, tray},
feat::clean_async, feat::run_exit_cleanup_flow,
process::AsyncHandler, process::AsyncHandler,
utils, utils,
}; };
use bytes::BytesMut; use bytes::BytesMut;
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use tristr::alias::String;
#[allow(clippy::expect_used)] #[allow(clippy::expect_used)]
static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> = LazyLock::new(|| { static TLS_CONFIG: LazyLock<Arc<rustls::ClientConfig>> =
let root_store = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); LazyLock::new(|| Arc::new(utils::network::build_webpki_rooted_tls_config().expect("Failed to set TLS versions")));
let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.expect("Failed to set TLS versions")
.with_root_certificates(root_store)
.with_no_client_auth();
Arc::new(config)
});
pub async fn restart_clash_core() { pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await { match CoreManager::global().restart_core().await {
@@ -38,38 +31,9 @@ pub async fn restart_clash_core() {
pub async fn restart_app() { pub async fn restart_app() {
logging!(debug, Type::System, "启动重启应用流程"); logging!(debug, Type::System, "启动重启应用流程");
handle::Handle::global().set_is_exiting(); let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppRestartCoreStopFailed).await;
Config::apply_all_and_save_file().await;
let cleanup_result = clean_async().await;
logging!(
info,
Type::System,
"资源清理完成,退出代码: {}",
if cleanup_result.all_success { 0 } else { 1 }
);
if !cleanup_result.core_stopped { if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppRestartCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
);
return; return;
} }
@@ -103,11 +67,13 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
} }
let clash = Config::clash().await; let clash = Config::clash().await;
clash.edit_draft(|d| d.patch_config(&mapping)); let save_result = {
clash.apply(); let _config_write = Config::lock_config_write().await;
clash.edit_draft(|d| d.patch_config(&mapping));
let clash_data = clash.data_arc(); clash.apply();
if clash_data.save_config().await.is_ok() { clash.data_arc().save_config().await
};
if save_result.is_ok() {
announce(Refresh::Clash); announce(Refresh::Clash);
tray::Tray::global().update_menu_and_icon().await; tray::Tray::global().update_menu_and_icon().await;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> {
/// Apply a patch without post-update reconciliation. /// Apply a patch without post-update reconciliation.
pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> { pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> {
let config_write = Config::try_lock_config_write()?; let config_write = Config::lock_config_write().await;
apply_verge_patch_locked(&config_write, patch, not_save_file).await apply_verge_patch_locked(&config_write, patch, not_save_file).await
} }
+10 -13
View File
@@ -21,7 +21,6 @@ use std::{
process::Command, process::Command,
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
}; };
use tristr::alias::String;
const RELEASE_VERSION_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt"; const RELEASE_VERSION_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt";
const ALPHA_BASE_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha"; const ALPHA_BASE_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha";
@@ -51,7 +50,7 @@ pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
// A core already broken by the in-place updater cannot report a version; upgrading is the repair. // A core already broken by the in-place updater cannot report a version; upgrading is the repair.
let installed = read_core_version(&target).unwrap_or_else(|error| { let installed = read_core_version(&target).unwrap_or_else(|error| {
logging!(warn, Type::Core, "core upgrade: unreadable installed core: {error:#}"); logging!(warn, Type::Core, "core upgrade: unreadable installed core: {error:#}");
String::new() std::string::String::new()
}); });
let (proxy, latest) = resolve_latest_version(alpha).await?; let (proxy, latest) = resolve_latest_version(alpha).await?;
@@ -173,15 +172,14 @@ fn managed_core_path(core: &str) -> Result<PathBuf> {
} }
/// Pins the package to the resolved version, so a release moving on mid-upgrade cannot 404. /// Pins the package to the resolved version, so a release moving on mid-upgrade cannot 404.
fn package_url(alpha: bool, version: &str) -> Result<String> { fn package_url(alpha: bool, version: &str) -> Result<std::string::String> {
let asset = asset_base_name(alpha)?; let asset = asset_base_name(alpha)?;
let extension = if cfg!(windows) { "zip" } else { "gz" }; let extension = if cfg!(windows) { "zip" } else { "gz" };
Ok(if alpha { Ok(if alpha {
format!("{ALPHA_BASE_URL}/{asset}-{version}.{extension}") format!("{ALPHA_BASE_URL}/{asset}-{version}.{extension}")
} else { } else {
format!("{RELEASE_DOWNLOAD_URL}/{version}/{asset}-{version}.{extension}") format!("{RELEASE_DOWNLOAD_URL}/{version}/{asset}-{version}.{extension}")
} })
.into())
} }
/// Mirrors the asset map in `scripts/prebuild.mjs` so an upgrade keeps the build variant /// Mirrors the asset map in `scripts/prebuild.mjs` so an upgrade keeps the build variant
@@ -220,11 +218,11 @@ fn asset_base_name(alpha: bool) -> Result<&'static str> {
/// Returns the proxy that reached GitHub so the package download reuses it. /// Returns the proxy that reached GitHub so the package download reuses it.
#[tracing::instrument(skip_all, level = "debug", fields(alpha))] #[tracing::instrument(skip_all, level = "debug", fields(alpha))]
async fn resolve_latest_version(alpha: bool) -> Result<(ProxyType, String)> { async fn resolve_latest_version(alpha: bool) -> Result<(ProxyType, std::string::String)> {
let url = if alpha { let url = if alpha {
String::from(format!("{ALPHA_BASE_URL}/version.txt")) format!("{ALPHA_BASE_URL}/version.txt")
} else { } else {
String::from(RELEASE_VERSION_URL) RELEASE_VERSION_URL.to_owned()
}; };
let mut last_error = None; let mut last_error = None;
@@ -236,7 +234,7 @@ async fn resolve_latest_version(alpha: bool) -> Result<(ProxyType, String)> {
match attempt { match attempt {
Ok(response) if response.status().is_success() => { Ok(response) if response.status().is_success() => {
// An error page answered with 200 must not become a version, nor reach the URL. // An error page answered with 200 must not become a version, nor reach the URL.
let version = String::from(response.text().trim()); let version = response.text().trim().to_owned();
if !is_usable_version(&version) { if !is_usable_version(&version) {
logging!( logging!(
warn, warn,
@@ -448,7 +446,7 @@ fn new_command(program: impl AsRef<OsStr>) -> Command {
} }
/// Parses the version out of `Mihomo Meta v1.19.30 darwin arm64 with go1.22.12 ...`. /// Parses the version out of `Mihomo Meta v1.19.30 darwin arm64 with go1.22.12 ...`.
fn read_core_version(path: &Path) -> Result<String> { fn read_core_version(path: &Path) -> Result<std::string::String> {
let output = new_command(path) let output = new_command(path)
.arg("-v") .arg("-v")
.output() .output()
@@ -458,12 +456,11 @@ fn read_core_version(path: &Path) -> Result<String> {
bail!("{} -v exited with {}", path.display(), output.status); bail!("{} -v exited with {}", path.display(), output.status);
} }
// from_utf8_lossy exists only on the std String type; the tristr alias shadows the bare name. let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = std::string::String::from_utf8_lossy(&output.stdout);
stdout stdout
.split_whitespace() .split_whitespace()
.nth(2) .nth(2)
.map(String::from) .map(str::to_owned)
.with_context(|| format!("unexpected version output from {}: {stdout}", path.display())) .with_context(|| format!("unexpected version output from {}: {stdout}", path.display()))
} }
+5 -2
View File
@@ -2,7 +2,7 @@ use crate::core::notify::{Refresh, announce};
use anyhow::{Result, ensure}; use anyhow::{Result, ensure};
use clash_verge_draft::DraftTransaction; use clash_verge_draft::DraftTransaction;
use serde::Serialize; use serde::Serialize;
use tristr::alias::String; use smartstring::alias::String;
use crate::{ use crate::{
config::{ config::{
@@ -19,7 +19,10 @@ use crate::{
#[serde(tag = "status", rename_all = "snake_case")] #[serde(tag = "status", rename_all = "snake_case")]
pub enum DnsOverrideOutcome { pub enum DnsOverrideOutcome {
Applied, Applied,
ConfirmationRequired { source: String }, ConfirmationRequired {
#[specta(type = std::string::String)]
source: String,
},
} }
pub async fn set_dns_override( pub async fn set_dns_override(
+9 -10
View File
@@ -1,17 +1,20 @@
use crate::{ use crate::{
cmd::{CmdResult, CommandFailure, StringifyErr as _}, cmd::{CmdResult, StringifyErr as _},
utils::dirs::{self, PathBufExec as _}, utils::dirs::{self, PathBufExec as _},
}; };
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use smartstring::alias::String;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use tokio::fs; use tokio::fs;
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
use tristr::alias::String;
#[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)] #[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub struct IconInfo { pub struct IconInfo {
#[specta(type = std::string::String)]
name: String, name: String,
#[specta(type = std::string::String)]
previous_t: String, previous_t: String,
#[specta(type = std::string::String)]
current_t: String, current_t: String,
} }
@@ -40,7 +43,7 @@ fn ensure_icon_cache_target(icon_cache_dir: &Path, file_name: &str) -> CmdResult
Ok(icon_path) Ok(icon_path)
} }
fn normalized_text_prefix(content: &[u8]) -> String { fn normalized_text_prefix(content: &[u8]) -> std::string::String {
let content = content.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(content); let content = content.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(content);
let start = content let start = content
.iter() .iter()
@@ -48,8 +51,7 @@ fn normalized_text_prefix(content: &[u8]) -> String {
.unwrap_or(content.len()); .unwrap_or(content.len());
let end = content.len().min(start.saturating_add(2048)); let end = content.len().min(start.saturating_add(2048));
let prefix = &content[start..end]; let prefix = &content[start..end];
// from_utf8_lossy exists only on the std String type; the tristr alias shadows the bare name. std::string::String::from_utf8_lossy(prefix).to_ascii_lowercase()
std::string::String::from_utf8_lossy(prefix).to_ascii_lowercase().into()
} }
fn looks_like_html(content: &[u8]) -> bool { fn looks_like_html(content: &[u8]) -> bool {
@@ -93,10 +95,7 @@ pub async fn download_icon_cache(url: String, name: String) -> CmdResult<String>
if !is_supported_icon_content(&content) { if !is_supported_icon_content(&content) {
let _ = temp_path.remove_if_exists().await; let _ = temp_path.remove_if_exists().await;
return Err(CommandFailure::plain(format!( return Err(format!("Downloaded content is not a valid image: {}", url.as_str()).into());
"Downloaded content is not a valid image: {}",
url.as_str()
)));
} }
{ {
@@ -165,7 +164,7 @@ pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult<Stri
match fs::copy(file_path, &dest_path).await { match fs::copy(file_path, &dest_path).await {
Ok(_) => Ok(dest_path.to_string_lossy().into()), Ok(_) => Ok(dest_path.to_string_lossy().into()),
Err(err) => Err(CommandFailure::plain(err)), Err(err) => Err(err.to_string().into()),
} }
} else { } else {
Err("file not found".into()) Err("file not found".into())
+5 -27
View File
@@ -7,8 +7,8 @@ use crate::{
utils::help::{mask_err, mask_url}, utils::help::{mask_err, mask_url},
}; };
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging};
use tristr::alias::String; use smartstring::alias::String;
/// Toggle proxy profile /// Toggle proxy profile
pub async fn toggle_proxy_profile(profile_index: String) { pub async fn toggle_proxy_profile(profile_index: String) {
@@ -47,26 +47,11 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
record_switched_node(group_name, proxy_name).await; record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies); announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await; let _ = tray::Tray::global().update_menu().await;
return;
} }
Err(err) => { Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}"); logging!(error, Type::Tray, "切换代理失败: {err:?}");
} }
} }
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "代理切换最终失败: {err:?}");
}
}
} }
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> { async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
@@ -217,10 +202,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
if should_refresh { if should_refresh {
logging!(debug, Type::Config, "[订阅更新] 更新内核配置"); logging!(debug, Type::Config, "[订阅更新] 更新内核配置");
match CoreManager::global().update_config_with_force(is_mannual_trigger).await { match CoreManager::global().update_config_with_force(is_mannual_trigger).await {
Ok(outcome) if outcome.is_valid() => { Ok(outcome) if outcome.is_valid() => announce(Refresh::Clash),
logging_error!(Type::Config, Config::sync_dns_override().await);
announce(Refresh::Clash);
}
Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => { Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => {
logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome); logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome);
} }
@@ -231,7 +213,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
}; };
let message = mask_err(&message); let message = mask_err(&message);
let message = if profile_persisted { let message = if profile_persisted {
format!("订阅已保存,但未能确认已应用到内核: {message}").into() format!("订阅已保存,但未能确认已应用到内核: {message}")
} else { } else {
message message
}; };
@@ -247,9 +229,5 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual
/// 增强配置 /// 增强配置
pub async fn enhance_profiles() -> Result<ValidationOutcome> { pub async fn enhance_profiles() -> Result<ValidationOutcome> {
let outcome = CoreManager::global().update_config_forced().await?; CoreManager::global().update_config_forced().await
if outcome.is_valid() {
logging_error!(Type::Config, Config::sync_dns_override().await);
}
Ok(outcome)
} }
+10 -5
View File
@@ -130,9 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}"); logging!(info, Type::Window, "Window toggle result: {result:?}");
} }
pub async fn quit() { pub(crate) async fn run_exit_cleanup_flow(stop_failed_notice: NoticeStatus) -> CleanupResult {
logging!(debug, Type::System, "启动退出流程");
// 设置退出标志
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
Config::apply_all_and_save_file().await; Config::apply_all_and_save_file().await;
@@ -162,11 +160,18 @@ pub async fn quit() {
} }
} }
handle::Handle::notice( handle::Handle::notice(
NoticeStatus::AppQuitCoreStopFailed, stop_failed_notice,
cleanup_result.stop_error.unwrap_or_default(), cleanup_result.stop_error.clone().unwrap_or_default(),
); );
} }
cleanup_result
}
pub async fn quit() {
logging!(debug, Type::System, "启动退出流程");
let cleanup_result = run_exit_cleanup_flow(NoticeStatus::AppQuitCoreStopFailed).await;
utils::server::shutdown_embedded_server(); utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
+5 -6
View File
@@ -1,5 +1,4 @@
use crate::cmd; use crate::cmd;
use tristr::alias::String;
pub fn builder() -> tauri_specta::Builder<tauri::Wry> { pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::new() tauri_specta::Builder::new()
@@ -122,7 +121,7 @@ pub struct WebDavFile {
impl From<reqwest_dav::list_cmd::ListFile> for WebDavFile { impl From<reqwest_dav::list_cmd::ListFile> for WebDavFile {
fn from(file: reqwest_dav::list_cmd::ListFile) -> Self { fn from(file: reqwest_dav::list_cmd::ListFile) -> Self {
Self { Self {
href: file.href.into(), href: file.href,
last_modified: file.last_modified, last_modified: file.last_modified,
} }
} }
@@ -149,20 +148,20 @@ pub struct IpAddress {
impl From<network_interface::NetworkInterface> for NetworkInterfaceView { impl From<network_interface::NetworkInterface> for NetworkInterfaceView {
fn from(interface: network_interface::NetworkInterface) -> Self { fn from(interface: network_interface::NetworkInterface) -> Self {
Self { Self {
name: interface.name.into(), name: interface.name,
addr: interface addr: interface
.addr .addr
.into_iter() .into_iter()
.map(|address| match address { .map(|address| match address {
network_interface::Addr::V4(address) => InterfaceAddress::V4(IpAddress { network_interface::Addr::V4(address) => InterfaceAddress::V4(IpAddress {
ip: address.ip.to_string().into(), ip: address.ip.to_string(),
}), }),
network_interface::Addr::V6(address) => InterfaceAddress::V6(IpAddress { network_interface::Addr::V6(address) => InterfaceAddress::V6(IpAddress {
ip: address.ip.to_string().into(), ip: address.ip.to_string(),
}), }),
}) })
.collect(), .collect(),
mac_addr: interface.mac_addr.map(Into::into), mac_addr: interface.mac_addr,
} }
} }
} }

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