Compare commits

...
64 Commits
Author SHA1 Message Date
wonfen ea509b8236 chore(deps): pin clash-verge-service-ipc to v2.7.6 2026-10-02 22:57:11 +08:00
wonfen 52d616302c fix(service): explain a startup core rejection instead of the generic fallback
When the startup probe finds the service refusing the approved core, the
app falls back to Sidecar with only the generic "could not start the
core" notice. The reason and repair guide appeared only after a
reinstall, which cannot fix a writable volume root (#8045).

Carry the fallback reason to the window and show the same permission
notice as a rejected installation when the core was refused.

Refs #8045
2026-10-02 10:38:09 +08:00
wonfen 8ddd99f226 chore: bump version v2.5.7 2026-09-30 22:07:17 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e0e97a920e chore(deps): update dependency pnpm to v12.8.1 (#8127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-30 06:27:19 +00:00
wonfen c4f9d65e07 fix(service): don't ask to repair the service when the proxy clear fails 2026-09-29 10:09:21 +08:00
wonfen 0c7e0c8535 fix(dns): persist DNS override confirmation across restarts
A confirmed override on a profile with provider DNS was turned off at
every startup; it now stays on until that profile's provider DNS changes.

Refs #7993
2026-09-28 13:09:09 +08:00
mrhard9090andClaude Opus 5.5 1638ab48d8 fix(layout): don't expose locked sidebar items as draggables (#8064)
While the menu order is locked, the sidebar items were still registered as
disabled sortables, so dnd-kit gave each nav button a draggable role
description, aria-disabled=true, aria-pressed and the drag instructions.
Screen readers announced every item as an unavailable draggable toggle.
Only wrap the items in SortableItem while reordering is unlocked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:37:54 +00:00
❤是纱雾酱哟~ 5d7dc3aed9 chore(gh-aw): Bump gh-aw to version v0.89.21 (#8072)
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-26 09:51:01 +00:00
wonfen b057bd964c fix clippy 2026-09-26 00:00:50 +08:00
wonfen 897a117dc5 chore(deps): bump tauri-plugin-mihomo for provider update timeouts
Pick up tauri-plugin-mihomo@ba8434c: provider updates now wait out
mihomo's 20s fetch instead of failing at 5s with "error sending request",
so slow subscriptions update and real failures show mihomo's own error.

Refs #8046
2026-09-25 16:09:57 +08:00
wonfen 6a752994cf fix(service): fail fast when the Windows service is stopped
A registered but stopped service made every version probe back off for
about two minutes, blocking startup before the window and core (#8059).
Check the SCM first; a service that is starting, or not yet started this
boot, still goes through the IPC retries.
2026-09-25 11:55:01 +08:00
wonfen 32e5d5bda3 chore(deps): pin clash-verge-service-ipc to v2.7.4
Replace the local path pin from fbc4b516 with the release tag, which
ships the installer group lookup fix (#8043) along with the residual
helper (#8031) and app data owner (#8026) service changes.
2026-09-25 09:09:14 +08:00
wonfen daae17a40c fix(service): surface a service core that stopped for good
When the service watchdog gave up or the core vanished, the owner
monitor marked the core stopped without telling the user, and on macOS
left the system proxy on the dead mixed port (#8029).

- On SameOwnerFailure, clear the proxy through the helper while the
  session is still ours; displaced or unreachable owners still leave the
  machine-wide setting alone.
- Retain the stop as a core failure with its own message, recorded
  under the lifecycle lock and shown now or on next focus; a running
  core resets the notice dedup.
- Log each watchdog restart the service reports, since launchd discards
  the service's own log.
2026-09-25 00:06:59 +08:00
wonfen 1f140dfd3a fix(service): show how to reclaim an app data root the service refuses
When the service refuses the app data root because another uid owns it
(code 1012), show a sticky notice with the chown command for this
account. Repairing the service cannot fix this.

Refs #8026
2026-09-24 22:10:42 +08:00
wonfen 04d74ac48f fix(service): offer repair when a residual helper blocks Sidecar
A macOS helper left running without IPC made Sidecar refuse to start
while the service counted as not installed, so no repair was offered
(#8031, #8043). Treat such a helper as install evidence and record the
Sidecar refusal as unavailable so the repair dialog appears.
2026-09-24 20:24:40 +08:00
wonfen 78bf79cc09 chore: update changelog 2026-09-24 18:52:24 +08:00
wonfen 61c33c6113 fix(core): retain startup diagnostics until recovery
Show unresolved startup failures when the window becomes visible and clear them when either core mode starts successfully. Cover recovery followed by exit and WebView recreation, use neutral service permission wording, and document the Windows startup fix.
2026-09-24 18:43:28 +08:00
wonfen ae4f416766 fix(service): explain core security failures and link repair guidance 2026-09-24 18:43:27 +08:00
wonfen fbc4b516b8 fix(service): fall back after installation verification rejects cores 2026-09-24 18:43:26 +08:00
3456de1fb6 fix(rules): disable auto-capitalization in rule content input (#8042)
* fix(rules): disable auto-capitalization in rule content input

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(rules): pass input hints to the rule content input element

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: wonfen <eiwonfen@gmail.com>
2026-09-24 15:49:19 +08:00
Tunglies f9ef9d78cb chore: bump version to 2.5.6 2026-09-24 15:25:05 +08:00
Tungliesand❤是纱雾酱哟~ 3458c9b075 fix(layout): restore "New" label in update button
Closes #8000

Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-24 15:23:35 +08:00
❤是纱雾酱哟~ afc32f568a chore(deps): Bump pnpm and cargo dependencies (#8022)
- Bump dependencies via `pnpm update`
- Bump dependencies via `cargo upgrade`
  - Incompatible dependencies already ignored
- Align Tauri-related dependencies version between `pnpm` and `cargo`
- Drop explicit forbidden of application downgrades

Assisted-by: vscode-copilot-chat:copilot:gpt-5.6-luna codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-23 07:03:15 +00:00
wonfen 22e3f1ac8a feat(profiles): highlight current extension step 2026-09-22 20:44:59 +08:00
wonfen 758300ac34 fix(dns): prioritize configured fields over extensions
Restore non-empty DNS page fields and hosts after Merge/Script processing while preserving extension values for empty fields. Keep provider protection tied to the original profile and retain per-profile confirmations.
2026-09-22 20:11:35 +08:00
wonfen 659ee1a181 feat(profiles): explain extension override order 2026-09-22 17:59:37 +08:00
wonfen fd06fbb3b7 fix(service): adopt IPC 2.7.3 and correct dev fallback handling 2026-09-22 17:37:26 +08:00
wonfen ed0b801573 fix(service): unify installation and safe sidecar handoff
Delegate service and core installation to Service IPC so replacement uses
one elevation. Share fallback occupancy checks and retain the execution
guard until the Sidecar exits.

Track missing approved cores and start refusals as unavailable, and show
when no core is running. Build matching service tools from the local IPC
dependency and pass both core executables through the Dev installer.
2026-09-22 14:55:24 +08:00
wonfen ff1ce92fac feat(dns): remember override state per profile
Save the DNS override switch and session confirmation per profile so automatic protection only disables the affected profile. Preserve the legacy switch as the initial preference and show the profile scope beside the settings gear and in the DNS editor.

Refs #7993
2026-09-22 10:54:55 +08:00
wonfen 6a85d0344e fix(enhance): replace DNS fields in merge overrides
Replace DNS map fields and hosts instead of retaining old entries, while preserving unspecified DNS fields. Keep DNS override priority and explicit empty values intact.

Refs #7993
2026-09-22 10:17:19 +08:00
wonfen bffe91db5e fix(service): prompt reinstallation after path permission rejection
Address #7983 with a recovery dialog for Windows service error 1003, including startup refusals received before the UI subscribes. Reinstall the service and require service mode before reporting recovery.
2026-09-22 09:02:56 +08:00
wonfen 93b7bb8fe8 fix(linux): stage approved cores when installing service
Pass available cores and their SHA-256 digests explicitly during service installation and repair, instead of relying on automatic discovery that can skip untrusted source locations. Preserve the arguments across root, pkexec, and sudo execution paths.

Fixes #7995
2026-09-22 08:41:55 +08:00
wonfen 3b958acb22 fix(config): isolate conflicting provider cache paths
Fixes #7989
2026-09-21 23:20:48 +08:00
wonfen d374ab4350 refactor(enhance): unify discarded-key notice state 2026-09-21 22:46:58 +08:00
wonfen b7197f6f4b fix(windows): fall back to sidecar after confirmed service failure 2026-09-21 21:48:13 +08:00
wonfen 7bf8e1dcf5 feat(enhance): warn when extensions write app-owned keys 2026-09-21 21:22:45 +08:00
wonfen 07825afb1e Reapply "feat(dns): protect provider DNS from unconfirmed overrides (#7902)"
This reverts commit df79ed3efb.
2026-09-21 21:22:45 +08:00
Tunglies d7e0126586 fix(updater): follow the latest stable release 2026-09-21 21:21:17 +08:00
oomeow 5059cdcb9d feat: build in asn mmdb (#7974)
* chore(scripts): add ASN mmdb resolution to prebuild tasks

* docs: update Changelog.md

* docs: update Cangelog.md

* feat: include ASN database in runtime resources
2026-09-21 13:03:03 +00:00
Tunglies af0f502f98 fix(macos): silence notification when hiding window with Cmd+W 2026-09-21 20:37:24 +08:00
Tunglies 8982525fd2 fix(updater): detect stable releases from build metadata versions 2026-09-21 19:11:30 +08:00
wonfen 81ce247ecc fix(service): repair initial owner identity and extend readiness wait 2026-09-21 17:32:48 +08:00
Tunglies b469bee352 fix(logging): include paths in file failure logs 2026-09-21 15:57:31 +08:00
Tunglies df79ed3efb Revert "feat(dns): protect provider DNS from unconfirmed overrides (#7902)"
This reverts commit 35a2b6c75c.

Closes #7994
Closes #7993
2026-09-21 15:08:33 +08:00
wonfen c6a4de1c6a fix(service): require exact version match and uninstall before reinstall 2026-09-21 07:52:51 +08:00
wonfen 433cd0628f fix(dns): replace configured override fields and prepare v2.5.5 2026-09-21 07:33:06 +08:00
Tunglies 13676d3335 Release 2.5.4 2026-09-20 19:49:13 +08:00
oomeow 2a7972fa5b fix(core): drain sidecar events before readiness probe to avoid start… (#7966)
* fix(core): drain sidecar events before readiness probe to avoid startup stall

* refactor(core): simplify core readiness generation handling

* docs: update Changelog.md
2026-09-20 09:43:31 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 19334368cd chore(deps): update dependency pnpm to v12.5.1 (#7976)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-20 07:43:37 +00:00
wonfen 73ea0fbd80 fix(dns): keep profile values the override leaves blank or off
Refs #7292 #7933
2026-09-20 15:08:33 +08:00
9f97d938f5 chore(i18n): complete Turkish (tr) translations (#7969)
Translate the 174 frontend strings and 8 tray/notification strings that were
still English copies in the Turkish locale. Values only; no keys changed.

Co-authored-by: qwist1233-cpu <qwist1233-cpu@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 09:21:06 +08:00
wonfen 04a2a09368 fix(linux): restore SELinux service startup with v2.7.2
Upgrade clash_verge_service_ipc and bundled service downloads to v2.7.2,
which labels runtime-installed executables before publication and repairs
existing core labels instead of timing out waiting for service IPC.

Register persistent bin_t rules for production and development service
binaries and cores in the RPM post-install script. Support older semanage
versions, apply rules to existing directories, and warn when optional
SELinux tools are unavailable.

Refs: #7965
2026-09-19 23:58:29 +08:00
❤是纱雾酱哟~ 94491b4ba4 chore(gh-aw): Bump gh-aw to version 0.89.15 (#7973)
Assisted-by: vscode-copilot-chat:deepseek-v4-pro codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-19 12:08:12 +00:00
❤是纱雾酱哟~ 78516dc1dc chore(renovate): Prevent auto-updates of gh-aw (#7972)
- Stop automatically updating dependencies of `gh-aw` related stuff
- Mend Renovate have no ability to distinguish stable and pre-release
- Manual update via `gh aw upgrade` is preffered

Assisted-by: vscode-copilot-chat:deepseek-flash codegraph

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-19 12:07:43 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8f6fd3bbf9 chore(deps): update github/gh-aw-actions action to v0.89.17 (#7971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-19 08:41:11 +00:00
Tunglies c599f5547e fix(proxy): validate user-entered URLs via isValidUrl
Delay test inputs now reject non-http(s) URLs with a warning toast and
fall back to the default URL instead of erroring every node in the
core. Subscription import and profile card host parsing reuse the same
validator, fixing bare-domain URLs shown as local files and
credential-bearing URLs leaking into the card.
2026-09-18 13:18:30 +08:00
wonfen f6fdb34745 perf(home): render proxy dropdown options on demand 2026-09-18 00:41:54 +08:00
wonfen 65ed8d3cf2 fix(home): stabilize proxy group and node dropdowns 2026-09-18 00:26:40 +08:00
Tunglies 2baa629ac4 chore: remove unimportant notice in changelog 2026-09-17 23:32:10 +08:00
Tunglies c7ffb21294 Release v2.5.4-rc 2026-09-17 18:55:59 +08:00
❤是纱雾酱哟~ 0d0ed72467 chore(renovate): Add pnpm version catch up in devcontainer.json (#7951)
- Remove non-exist devcontainer feature of `pnpm`
- Introduce custom managers for catching up `pnpm` version

Assisted-by: vscode-copilot-chat:deepseek-flash
2026-09-17 09:44:09 +00:00
wonfen a36dcd319f CI: update autobuild time 2026-09-17 11:03:03 +08:00
wonfen 9a879dd8d8 fix(service): fall back to elevated sidecar on path permission rejection
Preserve TUN settings and restore proxy operation after fallback.
Deliver fallback notices after UI subscription and stop retrying
deterministic bundle refusals. Publish fallback state atomically
and avoid conflicting service startup notifications.
2026-09-17 00:00:39 +08:00
oomeow 20141e791e fix(profile): virtualize editor lists for rule/proxy/group editors (#7944)
* fix(profile): virtualize editor lists for rule/proxy/group editors

* refactor(list): remove feedbackClone from sortable item and list integration

* refactor(profile): use type instead of export type for category

* refactor(ui): rename measureElementRef to measureElement in sortable components
2026-09-16 12:23:12 +00:00
179 changed files with 5601 additions and 3316 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0",
"pnpmVersion": "12.3.3"
"pnpmVersion": "12.8.1"
},
"ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1",
+41 -11
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v0.89.1": {
"github/gh-aw-actions/setup@v0.89.21": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
"version": "v0.89.21",
"sha": "924af5fdc64061cfbf66fb584c8b07e2ac230c60"
}
},
"containers": {
@@ -22,6 +22,16 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16",
"digest": "sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.16@sha256:57a3e27388a6d7d32719088581e52567727fa0bf2f0d477bf565b0c4baa12a3f"
},
"ghcr.io/github/gh-aw-firewall/agent:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23",
"digest": "sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.23@sha256:2c78aaba1c108e130e2d6d01e4f2cca334ea04c53e6f258913ac34173fe7e3b2"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13",
"digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -32,6 +42,16 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16",
"digest": "sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.16@sha256:cd400948638ffe1b87ec319abf73fa29b6ac9881b015da58bba971c4cc13a400"
},
"ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23",
"digest": "sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.23@sha256:c15c3d1208df10c5b588a3657be53742aa982ae0909d1eb1812525268794ca64"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -42,20 +62,30 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.20": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684"
"ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-firewall/squid:0.28.23": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23",
"digest": "sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.23@sha256:02ffc56dd40158223064ef03a78c2d9717473c93723b4e403d455ea6f0b09ae0"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.25": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.25",
"digest": "sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.25@sha256:9be0a86220e807a0ecc89e53d7453468f7a53fbc6b3d1efd2299025ffe01d086"
},
"ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node",
"digest": "sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e",
"pinned_image": "ghcr.io/github/gh-aw-node@sha256:bac2192f6374d6262116399b34fc5e143d576f82719e90a18261cae7480f4d4e"
},
"ghcr.io/github/github-mcp-server:v1.12.1": {
"image": "ghcr.io/github/github-mcp-server:v1.12.1",
"digest": "sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.1@sha256:0ba840c46a237879c8300e7fddb0b6347f20e029ccb9cbe2ce4a943daa1ff560"
"ghcr.io/github/github-mcp-server:v1.12.2": {
"image": "ghcr.io/github/github-mcp-server:v1.12.2",
"digest": "sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6",
"pinned_image": "ghcr.io/github/github-mcp-server:v1.12.2@sha256:508a0857ec762b1ab1cece29193345b501fab1dd9d1228a7b617062954cecac6"
}
}
}
+4 -4
View File
@@ -3,8 +3,8 @@ name: Auto Build
on:
workflow_dispatch:
schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: '0 4,10 * * *'
# UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 10,22 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
@@ -101,7 +101,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -580,7 +580,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
uses: github/gh-aw-actions/setup-cli@924af5fdc64061cfbf66fb584c8b07e2ac230c60 # v0.89.21
with:
version: v0.89.1
version: v0.89.15
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
@@ -691,7 +691,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -90,7 +90,7 @@ jobs:
- [常见问题](https://clash-verge-rev.github.io/faq/windows.html)
### 稳定机场VPN推荐
- [狗狗加速](https://verge.dginv.click/#/register?code=oaxsAGo6)
- [狗狗加速](https://cvr.dginv.click/#/register?code=oaxsAGo6)
Created at ${{ env.BUILDTIME }}.
EOF
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0
pnpm 12.4.1
pnpm 12.8.1
rust 1.98.1
Generated
+11 -11
View File
@@ -1079,7 +1079,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clash-verge"
version = "2.5.4"
version = "2.5.7"
dependencies = [
"aes-gcm",
"anyhow",
@@ -1223,8 +1223,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.7.1"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.1#807480e9afc7b9deb8ff3040b54be34326986dfd"
version = "2.7.6"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.6#b2a7427d6681c8d3780ab83a73f4dc21da2f22d6"
dependencies = [
"anyhow",
"kode-bridge",
@@ -6922,7 +6922,7 @@ dependencies = [
[[package]]
name = "sysproxy"
version = "0.7.1"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs.git?branch=main#44aaf00ec9c6779e5a461a55d882eddff7841c98"
source = "git+https://github.com/clash-verge-rev/sysproxy-rs.git?branch=main#89ad876a0942c049ae72e1472f9fc3ed62023c5e"
dependencies = [
"iptools",
"log",
@@ -7071,9 +7071,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.11.5"
version = "2.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5"
checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4"
dependencies = [
"anyhow",
"bytes",
@@ -7350,9 +7350,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.6.0"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
checksum = "d4c07d3be2c68e22e64012eb6c4ec969247e3800da26ef3f3856afbcd07a1ce6"
dependencies = [
"bytes",
"cookie_store",
@@ -7375,7 +7375,7 @@ dependencies = [
[[package]]
name = "tauri-plugin-mihomo"
version = "0.6.0"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#17c7757389ca73d29882753ecf7a160de29aabbe"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#ba8434c08c869916b8041d707ad66599bb5230b2"
dependencies = [
"arc-swap",
"clashmap",
@@ -7471,9 +7471,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
+2 -1
View File
@@ -63,7 +63,7 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1.44"
tauri = { version = "2.11.5" }
tauri = { version = "2.11.6" }
tauri-plugin-clipboard-manager = "2.3.3"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104"
@@ -105,6 +105,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
] }
# *********************************
+11 -79
View File
@@ -1,103 +1,35 @@
## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
## v2.5.7
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
- 修复读屏软件将普通模式下的侧边栏导航项播报为不可用的可拖拽控件的问题
- 修复订阅包含自定义 DNS 时,确认开启的 DNS 覆写在重启应用后被自动关闭的问题
**🖥️ Windows**
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 系统盘根目录的删除权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 部分系统安装服务失败,提示「program not found」的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- macOS 修复启动或重启应用时偶发内核启动失败,并误提示「需要更新系统服务」的问题
- macOS 修复 VPN 接管网络或开机网络未就绪时的问题:服务模式内核无法启动、TUN 不可用、系统代理状态读取报错
- macOS 修复用 `ipconfig set` 手动配置网卡后无法设置系统代理的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
- 修复 macOS/Linux 服务模式停止或重启内核时直接强制结束的问题:内核来不及正常退出、最后的日志丢失
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
- 优化服务模式的问题排查:服务自身的运行日志会保存到文件,便于追查内核被停止的原因
**🖥️ Windows**
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
- 优化 Windows 服务安全检查未通过时的启动提示:不再只显示「服务无法启动内核」,直接说明原因并提供修复文档链接
</details>
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: على وشك الخروج
body: Clash Verge على وشك الخروج.
appHidden:
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: Beenden steht bevor
body: Clash Verge wird gleich beendet.
appHidden:
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: About to Exit
body: Clash Verge is about to exit.
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: A punto de salir
body: Clash Verge está a punto de salir.
appHidden:
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: در آستانه خروج
body: Clash Verge در آستانه خروج است.
appHidden:
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: Akan Keluar
body: Clash Verge akan keluar.
appHidden:
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: 終了間近
body: Clash Verge はまもなく終了します。
appHidden:
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: 곧 종료
body: Clash Verge가 곧 종료됩니다.
appHidden:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: Скорый выход
body: Clash Verge скоро завершит работу.
appHidden:
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
updateReady:
title: Обновление Clash Verge
body: Новая версия (v{version}) скачана и готова к установке.
+8 -11
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
'on': System proxy has been enabled.
'off': System proxy has been disabled.
'on': Sistem vekili etkinleştirildi.
'off': Sistem vekili devre dışı bırakıldı.
systemProxyFailed:
title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın.
@@ -18,8 +18,8 @@ notifications:
body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled:
title: TUN Modu
'on': TUN mode has been enabled.
'off': TUN mode has been disabled.
'on': TUN modu etkinleştirildi.
'off': TUN modu devre dışı bırakıldı.
lightweightModeEntered:
title: Hafif Mod
body: Hafif moda geçildi.
@@ -29,14 +29,11 @@ notifications:
appQuit:
title: Çıkış Yapılmak Üzere
body: Clash Verge kapanmak üzere.
appHidden:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
title: Clash Verge Güncellemesi
body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Şimdi Kur
later: Daha Sonra
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: Чыгар алдыннан
body: Clash Verge чыгарга җыена.
appHidden:
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: 即将退出
body: Clash Verge 即将退出。
appHidden:
title: 应用已隐藏
body: Clash Verge 正在后台运行。
updateReady:
title: Clash Verge 更新
body: 新版本 (v{version}) 已下载完成,是否立即安装?
-3
View File
@@ -29,9 +29,6 @@ notifications:
appQuit:
title: 即將退出
body: Clash Verge 即將退出。
appHidden:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
updateReady:
title: Clash Verge 更新
body: 新版本(v{version})已下載完成,是否立即安裝?
+177
View File
@@ -1,3 +1,180 @@
## v2.5.6
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复「代理集合」「规则集合」订阅响应较慢时更新误报失败、且不显示真实失败原因的问题
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 修复 Windows 服务已停止时,启动要等约两分钟才显示窗口的问题
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
**🍎 macOS**
- macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题
- macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题
- macOS 修复部分账户安装或修复服务时提示无法解析用户组而失败的问题
- macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令
- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
</details>
## v2.5.5
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复不同来源的订阅或规则集共用缓存路径时,服务模式无法启动内核的问题
- 优化文件错误日志,显示具体文件路径和失败原因
- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
- 修复 DNS 覆写中已填写的字段被 Merge 或 Script 覆盖的问题,空字段继续使用扩展配置
- 修复 Merge 覆写 DNS 策略、过滤条件和 hosts 时,旧条目仍被保留的问题
- 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题
- 修复重新安装服务后内核缺失、无法继续使用 Sidecar 的问题
- 修复内核未运行时,首页仍显示服务模式的问题
- 修复服务重装重复请求管理员授权、卸载后误报失败的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
- 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
- 优化预构建资源,补充 ASN 数据库文件
- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃
</details>
## v2.5.4
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题
- 修复恢复备份后未按备份设置关闭系统代理的问题
- 修复服务模式下操作超时后,后续操作报错或状态异常的问题
- 修复部分节点显示「Unknown」或无法测试延迟的问题
- 修复 `PASS-RULE` 节点被错误隐藏的问题
- 修复重启内核后未恢复上次所选节点的问题
- 修复部分代理组和首页图标无法显示、页面报错的问题
- 修复同时修改端口、TUN 等设置时,部分修改丢失的问题
- 修复 TUN 模式下 IPv6 Fake-IP 默认地址范围错误的问题
- 修复切换节点时应用可能卡住的问题
- 修复订阅文件被误删、删除或排序后列表异常的问题
- 修复订阅自动更新失败无提示、重启后更新时间延后及状态误报的问题
- 修复订阅编辑器因节点名称为空而崩溃、因格式错误而丢失内容的问题
- 修复 DNS 编辑器空内容报错、初始值覆盖已有设置的问题
- 修复关闭自动检查更新后,手动检查更新无效的问题
- 修复应用冷启动或从轻量模式打开界面时, 系统代理被重复设置的问题
**🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS**
- 修复 macOS 内核更新失败后无法启动的问题
- 修复 macOS 需要管理员授权或开机自启时,系统代理设置失败的问题
- 修复 macOS 切换显示器后托盘速率样式不同步的问题
**🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux**
- 修复 macOS/Linux 系统代理绕过规则未正确生效的问题
- 修复 macOS/Linux 因旧版服务缓存权限异常而无法记住节点的问题
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口自动避让,被占用时临时使用其他端口
- 新增系统代理和 TUN 权限引导,可直接安装服务并恢复原设置
- 新增服务模式多用户隔离,支持旧版服务升级迁移
- 新增手动取消节点固定功能
- 新增代理组标题栏拖动调整,可交换组名和工具栏的位置
- 新增前置规则与后置规则互相移动功能
- 新增 DNS 覆写保护:订阅含自定义 DNS 时先确认,订阅更新后自动关闭覆写
- 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS**
- 新增 macOS Liquid Glass 风格图标
- 新增 macOS 多用户启动保护,避免多个实例冲突
**🐧 Linux**
- 新增 Linux 系统主题跟随,自动切换深浅色外观
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度
- 优化首页节点操作,显示节点固定状态
- 优化外部链接检查,仅允许打开 HTTP/HTTPS 网页
- 优化更新说明排版,支持表格和提示框
- 优化搜索框聚焦、界面字体和拖动排序动画
- 优化后台连接和托盘刷新,降低资源占用
- 优化解锁检测,支持同时检测多个项目并逐项显示结果
- 优化日志记录,减少重复信息
- 移除便携模式,统一使用系统应用数据目录
- 优化繁体中文、俄语翻译及语言名称显示
**🖥️ Windows**
- 优化 Windows 自动更新,跳过安装语言选择
**🐧 Linux**
- 优化 Linux 系统代理设置,减少额外命令依赖,并明确提示缺少的命令
</details>
## v2.5.2
### 🐞 修复问题
+3 -3
View File
@@ -51,11 +51,11 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
## Promotion
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://verge.dginv.click/#/register?code=oaxsAGo6)
### ✈️ [Doggygo VPN — A Technical-Grade Proxy Service](https://cvr.dginv.click/#/register?code=oaxsAGo6)
🚀 A high-performance, overseas, technical-grade proxy service offering free trials and discounted plans, fully unlocking streaming platforms and AI services. The world’s first provider to adopt the **QUIC protocol**.
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://verge.dginv.click/#/register?code=oaxsAGo6)
🎁 Register via the **Clash Verge exclusive invitation link** to receive **3 days of free trial**, with **1GB traffic per day**: 👉 [Register here](https://cvr.dginv.click/#/register?code=oaxsAGo6)
#### **Core Advantages:**
@@ -67,7 +67,7 @@ Join [@clash_verge_rev](https://t.me/clash_verge_re) for update announcements.
- ⚡ The world’s first **QUIC-protocol-based proxy service**, now featuring faster **QUIC-family protocols** (best paired with the Clash Verge client)
- 🎬 Unlocks **streaming platforms and mainstream AI services**
🌐 Official Website: 👉 [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
🌐 Official Website: 👉 [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-Powered Customer Service Platform Deeply Integrated with Crisp](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Consulta la [documentación del proyecto](https://clash-verge-rev.github.io/) pa
## Promociones
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — Acelerador global orientado al rendimiento](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Servicio internacional de alto rendimiento con prueba gratuita, planes con descuento, desbloqueo de streaming y soporte de protocolo Hysteria de primera clase.
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Regístrate mediante el enlace exclusivo de Clash Verge y obtén una prueba de 3 días con 1 GB de tráfico diario: [Regístrate](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Cupón exclusivo de 20% de descuento para usuarios de Clash Verge: `verge20` (limitado a 500 usos)
- Plan promocional desde ¥15.8 al mes con 160 GB, más 20% de descuento adicional por pago anual
- Equipo ubicado en el extranjero para un servicio confiable, con hasta 50% de comisión compartida
- Clústeres balanceados con rutas dedicadas de alta velocidad (compatibles con clientes antiguos), latencia extremadamente baja, reproducción 4K sin interrupciones
- Primer proveedor global con **protocolo QUIC**, ahora con protocolos de la familia QUIC más rápidos (ideal para el cliente Clash Verge)
- Desbloquea servicios de streaming y acceso a ChatGPT
- Sitio oficial: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Sitio oficial: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Plataforma de atención al cliente con IA integrada con Crisp](https://gptkefu.com)
+3 -3
View File
@@ -50,17 +50,17 @@
## تبلیغات
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — شتاب‌دهنده جهانی عملکردگرا](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- سرویس شبکه برون مرزی با عملکرد بالا به همراه دوره‌های آزمایشی رایگان، طرح‌های تخفیف‌دار، امکان باز کردن قفل استریم و پشتیبانی درجه یک از پروتکل هیستریا.
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://verge.dginv.click/#/register?code=oaxsAGo6)
- از طریق لینک اختصاصی Clash Verge ثبت نام کنید تا یک دوره آزمایشی ۳ روزه با ۱ گیگابایت ترافیک در روز دریافت کنید: [ثبت نام](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- کوپن تخفیف ۲۰٪ ویژه کاربران Clash Verge: `verge20` (محدود به ۵۰۰ بار استفاده)
- بسته تخفیف‌دار از ۱۵.۸ ین در ماه برای ۱۶۰ گیگابایت، به علاوه ۲۰٪ تخفیف اضافی برای صورتحساب سالانه
- توسط یک تیم خارجی با خدمات قابل اعتماد و تا 50٪ سهم درآمد اداره می‌شود
- کلاسترهای متعادل بار با مسیرهای اختصاصی پرسرعت (سازگار با کلاینت‌های قدیمی)، تأخیر فوق‌العاده کم، پخش روان 4K
- اولین ارائه‌دهنده جهانی با **پروتکل QUIC**، اکنون با پروتکل‌های سریع‌تر خانواده QUIC (بهترین ترکیب با کلاینت Clash Verge)
- پشتیبانی از سرویس‌های استریم و دسترسی به ChatGPT
- وبسایت رسمی: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- وبسایت رسمی: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — پلتفرم خدمات مشتری هوشمند مبتنی بر هوش مصنوعی با ادغام عمیق Crisp](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Windows (x64/x86)、Linux (x64/arm64)、macOS 10.15+ (Intel/Apple) をサポー
## プロモーション
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 高性能グローバルアクセラレータ](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 無料トライアル、割引プラン、ストリーミング解放、世界初の Hysteria プロトコル対応を備えた高性能海外ネットワークサービス。
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 専用リンクから登録すると、3 日間・1 日 1 GB の無料体験が利用できます。 [登録はこちら](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 利用者限定 20% オフクーポン: `verge20`(先着 500 名)
- 月額 15.8 元で 160 GB を利用できるプラン、年額契約ならさらに 20% オフ
- 海外チーム運営による高信頼サービス、収益シェアは最大 50%
- 負荷分散クラスタと高速専用回線(旧クライアント互換)、極低レイテンシで 4K も快適
- 世界初の **QUIC プロトコル**対応。より高速な QUIC 系プロトコルを提供(Clash Verge クライアントとの相性抜群)
- ストリーミングおよび ChatGPT の利用にも対応
- 公式サイト: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 公式サイト: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp と深く統合された AI スマートカスタマーサービスプラットフォーム](https://gptkefu.com)
+3 -3
View File
@@ -51,17 +51,17 @@ Windows (x64/x86), Linux (x64/arm64), macOS 10.15+ (Intel/Apple)을 지원합니
## 프로모션
#### [Doggygo VPN — 고성능 글로벌 가속기](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN — 고성능 글로벌 가속기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- 무료 체험, 할인 요금제, 스트리밍 해제, 선도적인 Hysteria 프로토콜 지원을 갖춘 고성능 해외 네트워크 서비스
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 초대 링크로 가입 시 3일간 매일 1GB 무료 체험 제공: [가입하기](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Clash Verge 전용 20% 할인 코드: `verge20` (선착순 500회)
- 월 15.8위안부터 160GB 제공, 연간 결제 시 추가 20% 할인
- 해외 팀 운영, 높은 신뢰성, 최대 50% 커미션
- 로드밸런싱 클러스터, 고속 전용 회선(구 클라이언트 호환), 매우 낮은 지연, 4K도 쾌적
- 세계 최초 **QUIC 프로토콜** 지원, 더 빠른 QUIC 계열 프로토콜 제공 (Clash Verge 클라이언트와 최적의 궁합)
- 스트리밍 및 ChatGPT 접근 지원
- 공식 사이트: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- 공식 사이트: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — Crisp과 긴밀히 통합된 AI 스마트 고객 서비스 플랫폼](https://gptkefu.com)
+3 -3
View File
@@ -47,17 +47,17 @@ Clash Meta GUI базируется на <a href="https://github.com/tauri-apps/
## Продвижение
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://verge.dginv.click/#/register?code=oaxsAGo6)
#### [Doggygo VPN —— технический VPN-сервис (айрпорт)](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Высокопроизводительный иностранный VPN-сервис (айрпорт) с бесплатным пробным периодом, выгодными тарифами, возможностью разблокировки потокового ТВ и первым в мире поддержкой протокола Hysteria.
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Зарегистрируйтесь по эксклюзивной ссылке Clash Verge и получите 3 дня бесплатного использования, 1 Гб трафика в день: [регистрация](https://cvr.dginv.click/#/register?code=oaxsAGo6)
- Эксклюзивный промо-код на скидку 20% для Clash Verge: verge20 (только 500 штук)
- Специальный тарифный план всего за 15,8 юаней в месяц, 160 Гб трафика, скидка 20% при оплате за год
- Команда за рубежом, без риска побега, до 50% кэшбэка
- Архитектура с балансировкойнагрузки, высокоскоростная выделенная линия (совместима со старыми клиентами), чрезвычайно низкая задержка, без проблем в часы пик, 4K видео загружается мгновенно
- Первый в мире VPN-сервис (айрпорт) на **протоколе QUIC**, теперь с более быстрыми протоколами семейства QUIC (лучшее сочетание с клиентом Clash Verge)
- Разблокировка потоковые сервисы и ChatGPT
- Официальный сайт: [https://狗狗加速.com](https://verge.dginv.click/#/register?code=oaxsAGo6)
- Официальный сайт: [https://狗狗加速.com](https://cvr.dginv.click/#/register?code=oaxsAGo6)
### 🤖 [GPTKefu — AI-платформа умного обслуживания клиентов с глубокой интеграцией Crisp](https://gptkefu.com)
+43 -43
View File
@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "2.5.4",
"version": "2.5.7",
"license": "GPL-3.0-only",
"scripts": {
"prepare": "husky || true",
@@ -49,88 +49,88 @@
"@emotion/styled": "^11.14.1",
"@juggle/resize-observer": "^3.4.0",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1",
"@tanstack/react-virtual": "^3.13.25",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.6.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-fs": "^2.5.2",
"@tauri-apps/plugin-http": "~2.6.1",
"@tauri-apps/plugin-opener": "2.5.5",
"@tauri-apps/plugin-updater": "2.11.0",
"ahooks": "^3.9.7",
"@tauri-apps/plugin-updater": "2.12.0",
"ahooks": "^3.10.0",
"cidr-block": "^2.3.0",
"compare-versions": "^6.1.1",
"dayjs": "1.11.23",
"foxact": "^0.3.4",
"foxts": "^5.4.2",
"i18next": "^26.2.0",
"js-yaml": "^5.0.0",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"js-yaml": "^5.4.2",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.25",
"meta-json-schema": "^1.19.31",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0",
"monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-error-boundary": "6.1.5",
"react-hook-form": "^7.76.1",
"react-hook-form": "^7.88.0",
"react-i18next": "17.0.14",
"react-markdown": "10.1.0",
"react-router": "^8.0.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"swr": "^2.4.2",
"swr": "^2.5.1",
"tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#main",
"types-pac": "^1.0.3",
"validator": "^13.15.35"
},
"devDependencies": {
"@actions/github": "^9.1.1",
"@biomejs/biome": "^2.4.15",
"@eslint-react/eslint-plugin": "^5.8.5",
"@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.11.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4",
"@types/node": "^24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.0",
"axios": "^1.16.1",
"adm-zip": "^0.6.1",
"axios": "^1.20.0",
"cli-color": "^2.0.4",
"commander": "^15.0.0",
"cross-env": "^10.1.0",
"eslint": "^10.4.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-es-x": "^10.0.0",
"eslint-plugin-import-x": "^4.16.2",
"eslint": "^10.11.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-es-x": "^10.0.1",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-sukka": "^10.0.0",
"eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1",
"glob": "^13.0.6",
"globals": "^17.6.0",
"https-proxy-agent": "^9.0.0",
"globals": "^17.12.0",
"https-proxy-agent": "^9.1.0",
"husky": "^9.1.7",
"jiti": "^2.7.0",
"knip": "6.35.1",
"lint-staged": "^17.0.5",
"sass": "^1.100.0",
"tar": "^7.5.15",
"terser": "^5.48.0",
"lint-staged": "^17.5.1",
"sass": "^1.104.1",
"tar": "^7.5.22",
"terser": "^5.51.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.14",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.0",
"vitest": "^5.0.1",
"webdriverio": "9.31.9",
"ws": "8.21.3"
},
@@ -144,5 +144,5 @@
]
},
"type": "module",
"packageManager": "pnpm@12.4.1"
"packageManager": "pnpm@12.8.1"
}
+984 -923
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -56,6 +56,12 @@
"matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false
},
{
"description": "Ignore gh-aw dependency updates in workflow files; upgrade agentic workflows with gh aw upgrade instead",
"matchFileNames": [".github/workflows/**"],
"matchPackageNames": ["/gh-aw/"],
"enabled": false
},
{
"description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"],
@@ -78,7 +84,7 @@
},
{
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"],
"matchPackageNames": ["pnpm"],
"groupName": "pnpm"
},
{
@@ -131,6 +137,16 @@
],
"depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases"
},
{
"customType": "regex",
"description": "Update pnpm in devcontainer.json",
"fileMatch": ["^\\.devcontainer/devcontainer\\.json$"],
"matchStrings": [
"\"ghcr\\.io/devcontainers/features/node:[^\"]+\":\\s*\\{[^}]*\"pnpmVersion\":\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "pnpm",
"datasourceTemplate": "npm"
}
],
"postUpdateOptions": ["pnpmDedupe"],
+86 -142
View File
@@ -1,36 +1,23 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync, spawn } from 'node:child_process'
import { access } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
export const developmentServiceWatchPaths = [
'src',
'resources',
'Cargo.toml',
'Cargo.lock',
].map((name) => join(serviceRepository, name))
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
@@ -39,10 +26,15 @@ function run(command, args, options = {}) {
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => {
stdout += chunk
})
child.once('error', reject)
child.once('exit', (code, signal) => {
child.once('close', (code, signal) => {
if (code === 0) {
resolvePromise()
resolvePromise(stdout)
return
}
const error = new Error(
@@ -54,134 +46,86 @@ function run(command, args, options = {}) {
})
}
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
function windowsElevationInvocation(installer, environment = process.env) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
export async function prepareDevelopmentService() {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const output = await run(
'cargo',
[
'build',
'--manifest-path',
serviceManifest,
'--target-dir',
process.env.CARGO_TARGET_DIR ||
join(serviceRepository, 'target', 'development'),
'--features',
'standalone,client,development-channel',
'--bins',
'--message-format=json-render-diagnostics',
],
{ stdio: ['inherit', 'pipe', 'inherit'] },
)
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
const artifacts = new Map()
for (const line of output.split(/\r?\n/).filter(Boolean)) {
const artifact = JSON.parse(line)
if (
artifact.reason === 'compiler-artifact' &&
resolve(artifact.manifest_path) === serviceManifest &&
artifact.target.kind.includes('bin') &&
artifact.executable
) {
artifacts.set(artifact.target.name, resolve(artifact.executable))
}
}
const executables = [
'clash-verge-service',
'clash-verge-service-install',
'clash-verge-service-uninstall',
'service-integration-driver',
].map((name) => {
const executable = artifacts.get(name)
if (!executable)
throw new Error(`Cargo did not report the ${name} executable`)
return executable
})
const serviceDirectory = dirname(executables[0])
for (const executable of executables) {
if (dirname(executable) !== serviceDirectory) {
throw new Error(
'Development service tools were built in different directories',
)
}
await access(executable)
}
return serviceDirectory
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const serviceDirectory = await prepareDevelopmentService()
const extension = platform === 'win32' ? '.exe' : ''
const installer = join(
serviceDirectory,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
`clash-verge-service-install${extension}`,
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
const host = execFileSync('rustc', ['-vV'], { encoding: 'utf8' }).match(
/^host: (.+)$/m,
)?.[1]
if (!host) throw new Error('rustc did not report its host target')
const args = ['--prepare-install', '--ensure']
for (const name of ['verge-mihomo', 'verge-mihomo-alpha']) {
args.push(
'--core',
`${name}${extension}`,
join(
repositoryRoot,
'src-tauri',
'sidecar',
`${name}-${host}${extension}`,
),
)
}
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
await run(installer, args)
return serviceDirectory
}
+4
View File
@@ -10,6 +10,7 @@ import {
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
developmentServiceWatchPaths,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
@@ -31,6 +32,9 @@ export function buildTauriInvocation(
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
for (const path of developmentServiceWatchPaths) {
args.push('--additional-watch-folders', path)
}
return {
command: 'pnpm',
args,
+53 -11
View File
@@ -1,9 +1,9 @@
import { execFileSync, execSync } from 'child_process'
import { createHash } from 'crypto'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import zlib from 'zlib'
import { execFileSync, execSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import zlib from 'node:zlib'
import AdmZip from 'adm-zip'
import { glob } from 'glob'
@@ -485,7 +485,7 @@ const resolvePlugin = async () => {
const zip = new AdmZip(tempZip)
zip
.getEntries()
.forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName))
.forEach((entry) => void log_debug(`"SimpleSC" entry`, entry.entryName))
zip.extractAllTo(tempDir, true)
if (fs.existsSync(tempDll)) {
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
@@ -553,7 +553,7 @@ const SERVICE_BINARIES = [
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
@@ -586,6 +586,41 @@ async function resolveServiceBundle() {
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const serviceDependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const sourcePath = serviceDependency?.match(/\bpath\s*=\s*"([^"]+)"/)?.[1]
if (sourcePath) {
const manifest = path.resolve(cwd, 'src-tauri', sourcePath, 'Cargo.toml')
const targetDirectory = path.join(cwd, 'target', 'bundled-service')
execFileSync(
'cargo',
[
'build',
'--manifest-path',
manifest,
'--target-dir',
targetDirectory,
'--target',
SIDECAR_HOST,
'--release',
'--features',
'standalone,client',
'--bins',
],
{ stdio: 'inherit' },
)
await fsp.mkdir(SERVICE_DIR, { recursive: true })
for (const { sourceFile, targetPath } of files) {
await fsp.copyFile(
path.join(targetDirectory, SIDECAR_HOST, 'release', sourceFile),
targetPath,
)
if (platform !== 'win32') await fsp.chmod(targetPath, 0o755)
await updateHashCache(targetPath)
}
return
}
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
@@ -604,8 +639,9 @@ async function resolveServiceBundle() {
const zip = new AdmZip(tempArchive)
zip
.getEntries()
.forEach((entry) =>
log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
.forEach(
(entry) =>
void log_debug('"clash-verge-service-ipc" entry:', entry.entryName),
)
zip.extractAllTo(tempDir, true)
} else {
@@ -652,7 +688,7 @@ async function resolveCoreHashes() {
.digest('hex')
lines.push(`!define ${define} "${digest}"`)
}
await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n')
await fsp.writeFile(CORE_HASHES_NSH, `${lines.join('\n')}\n`)
log_success(`Generated ${CORE_HASHES_NSH}`)
}
@@ -661,6 +697,11 @@ const resolveMmdb = () =>
file: 'Country.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`,
})
const resolveASNMmdb = () =>
resolveResource({
file: 'ASN.mmdb',
downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb`,
})
const resolveGeosite = () =>
resolveResource({
file: 'geosite.dat',
@@ -706,6 +747,7 @@ const tasks = [
{ name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true },
{ name: 'service', func: resolveServiceBundle, retry: 5 },
{ name: 'mmdb', func: resolveMmdb, retry: 5 },
{ name: 'asn_mmdb', func: resolveASNMmdb, retry: 5 },
{ name: 'geosite', func: resolveGeosite, retry: 5 },
{ name: 'geoip', func: resolveGeoIP, retry: 5 },
{
+50 -15
View File
@@ -1,5 +1,5 @@
/**
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, and src-tauri/tauri.conf.json.
* CLI tool to update version numbers in package.json, src-tauri/Cargo.toml, Cargo.lock, and src-tauri/tauri.conf.json.
*
* Usage:
* pnpm release-version <version>
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ',
packageJson.version,
)
packageJson.version = newVersion.startsWith('v')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in package.json')
}
await fs.writeFile(packageJsonPath, updatedData, 'utf8')
console.log(`[INFO]: package.json version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating package.json version:', error)
throw error
@@ -153,6 +156,34 @@ async function updateCargoVersion(newVersion) {
}
}
async function updateCargoLockVersion(newVersion) {
const _dirname = process.cwd()
const cargoLockPath = path.join(_dirname, 'Cargo.lock')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion
const packageVersionPattern =
/(\[\[package\]\]\r?\nname = "clash-verge"\r?\nversion = )"[^"]+"/
try {
const data = await fs.readFile(cargoLockPath, 'utf8')
const updatedData = data.replace(
packageVersionPattern,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('clash-verge package entry was not found in Cargo.lock')
}
await fs.writeFile(cargoLockPath, updatedData, 'utf8')
console.log(`[INFO]: Cargo.lock version updated to: ${versionWithoutV}`)
} catch (error) {
console.error('Error updating Cargo.lock version:', error)
throw error
}
}
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version,
)
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
`$1"${versionWithoutV}"`,
)
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
)
@@ -239,6 +273,7 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
+3 -12
View File
@@ -14,22 +14,13 @@ async function resolveUpdater() {
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
})
const tag = tags.find((t) => t.name.startsWith('v'))
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const tag = { name: latestRelease.tag_name }
console.log(tag)
console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md
+3 -2
View File
@@ -40,10 +40,11 @@ async function resolveUpdater() {
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
const stableTagRegex = /^v\d+\.\d+\.\d+$/
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const { data: latestRelease } =
await github.rest.repos.getLatestRelease(options)
const stableTag = { name: latestRelease.tag_name }
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log('All tags:', tags.map((t) => t.name).join(', '))
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "clash-verge"
version = "2.5.4"
version = "2.5.7"
description = "clash verge"
authors = ["zzzgydi", "Tunglies", "wonfen", "MystiPanda"]
license = "GPL-3.0-only"
@@ -94,12 +94,12 @@ scopeguard = "1.2.0"
tauri-plugin-notification = "2.4.0"
tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.6.0"
tauri-plugin-http = "2.6.1"
tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.1", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.1", version = "2.7.1", features = [
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 = [
"client",
] }
arc-swap = "1.9.2"
@@ -148,7 +148,7 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.11.0"
tauri-plugin-updater = "2.12.0"
[dev-dependencies]
criterion = { workspace = true }
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
fi
if [ ! -x /usr/sbin/semanage ] || [ ! -x /usr/sbin/restorecon ]; then
echo "Warning: persistent SELinux rules were not installed; install policycoreutils and policycoreutils-python-utils, then reinstall this package. The service installer repairs executable labels, but a system relabel can reset them." >&2
exit 0
fi
# Keep these local rules on removal: they may predate this package or serve a standalone development service.
for service in clash-verge-service clash-verge-service-dev; do
for directory in bin cores; do
target="/var/lib/$service/$directory"
if [ -L "/var/lib/$service" ] || [ -L "$target" ]; then
echo "Refusing to relabel a symlinked service directory: $target" >&2
exit 1
fi
spec="$target(/.*)?"
# Older semanage versions reject -a when an exact rule already exists.
if ! add_error="$(/usr/sbin/semanage fcontext -a -t bin_t "$spec" 2>&1)"; then
if ! /usr/sbin/semanage fcontext -m -t bin_t "$spec"; then
printf '%s\n' "$add_error" >&2
exit 1
fi
fi
if [ -d "$target" ]; then
/usr/sbin/restorecon -R "$target"
fi
done
done
+21 -6
View File
@@ -133,8 +133,12 @@ pub fn take_dns_override_notice() -> bool {
}
#[tauri::command]
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(enabled, confirmation)
pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> CmdResult<feat::DnsOverrideOutcome> {
feat::set_dns_override(profile_uid, enabled, confirmation)
.await
.map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED"))
}
@@ -145,16 +149,26 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult {
let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
logging!(warn, Type::Config, "DNS config file not found");
logging!(warn, Type::Config, "DNS config file not found: {}", dns_path.display());
return Err("DNS config file not found".into());
}
let dns_yaml = fs::read_to_string(&dns_path).await.stringify_err_log(|e| {
logging!(error, Type::Config, "Failed to read DNS config: {e}");
logging!(
error,
Type::Config,
"Failed to read DNS config {}: {e}",
dns_path.display()
);
})?;
let patch_config = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml).stringify_err_log(|e| {
logging!(error, Type::Config, "Failed to parse DNS config: {e}");
logging!(
error,
Type::Config,
"Failed to parse DNS config {}: {e}",
dns_path.display()
);
})?;
let mut patch = serde_yaml_ng::Mapping::new();
@@ -210,7 +224,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
}
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
// A fragment, not a runnable config; the merged result is validated on apply.
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
.await
.stringify_err()
}
+5
View File
@@ -32,6 +32,11 @@ pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String
Ok(Config::runtime().await.latest_arc().chain_logs.clone())
}
#[tauri::command]
pub fn take_discarded_keys_notice() -> Option<String> {
crate::enhance::take_discarded_keys_notice()
}
#[tauri::command]
pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult<String> {
let runtime = Config::runtime().await;
+162 -17
View File
@@ -8,26 +8,81 @@ use crate::{
},
};
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult {
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum ServiceInstallOutcome {
Installed,
#[cfg(any(windows, test))]
Sidecar {
reason: String,
},
}
async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult<ServiceInstallOutcome> {
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER
.handle_service_status(status)
.await
let result = {
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
&status,
ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired
) {
manager
.controlled_stop_core_inner()
.await
.map_err(|error| proxy_aware_coded_error(&error, error_code))?;
}
SERVICE_MANAGER.handle_service_status(status).await
};
#[cfg(windows)]
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
#[cfg(not(windows))]
result
.map(|()| ServiceInstallOutcome::Installed)
.map_err(|error| proxy_aware_coded_error(&error, error_code))
}
#[cfg(any(windows, test))]
async fn finish_service_installation<Fallback, FallbackFuture>(
installation: anyhow::Result<()>,
error_code: &str,
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce() -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
let Err(error) = installation else {
return Ok(ServiceInstallOutcome::Installed);
};
let Some(failure) = error.downcast_ref::<clash_verge_service_ipc::management::InstallationVerificationError>()
else {
return Err(proxy_aware_coded_error(&error, error_code));
};
let reasons = failure
.status
.cores
.iter()
.filter_map(|core| match &core.availability {
clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)),
_ => None,
})
.collect::<Vec<_>>();
if reasons.is_empty() {
return Err(proxy_aware_coded_error(&error, error_code));
}
let reason = reasons.join("\n");
fallback().await.map_err(|error| {
proxy_aware_coded_error(
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
"SERVICE_SIDECAR_FAILED",
)
})?;
Ok(ServiceInstallOutcome::Sidecar { reason })
}
#[tauri::command]
pub async fn install_service() -> CmdResult {
pub async fn install_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await
}
@@ -40,12 +95,12 @@ pub async fn uninstall_service() -> CmdResult {
}
#[tauri::command]
pub async fn reinstall_service() -> CmdResult {
pub async fn reinstall_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await
}
#[tauri::command]
pub async fn repair_service() -> CmdResult {
pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await
}
@@ -57,9 +112,99 @@ pub async fn continue_with_sidecar() -> CmdResult {
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
}
#[tauri::command]
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn get_core_startup_error() -> Option<crate::core::manager::CoreFailure> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn take_service_owner_notice() -> Option<String> {
crate::core::service::take_service_owner_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{ServiceInstallOutcome, finish_service_installation};
use clash_verge_service_ipc::{
CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError,
};
use std::cell::Cell;
#[tokio::test]
async fn rejected_installation_reports_sidecar_only_after_successful_continuation() {
let reason = "core path C:\\ is writable by Everyone";
for (availability, fallback_ready) in [
(None, false),
(Some(CoreAvailability::Missing), false),
(Some(CoreAvailability::DigestMismatch), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), false),
(Some(CoreAvailability::Rejected { reason: reason.into() }), true),
] {
let fallback_called = Cell::new(false);
let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. }));
let error = match availability {
Some(availability) => anyhow::Error::new(InstallationVerificationError {
status: InstallationStatus {
service_sha256: String::new(),
protocol: ProtocolInfo::current(),
cores: vec![CoreInspection {
name: "verge-mihomo.exe".into(),
availability,
}],
core_busy: false,
},
}),
None => anyhow::anyhow!("{reason}"),
}
.context("install service failed");
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
fallback_called.set(true);
anyhow::ensure!(fallback_ready, "another core is running");
Ok(())
})
.await;
assert_eq!(fallback_called.get(), rejected);
if rejected && fallback_ready {
assert_eq!(
result,
Ok(ServiceInstallOutcome::Sidecar {
reason: format!("verge-mihomo.exe: {reason}")
})
);
} else {
let error = result.expect_err("an unavailable fallback must remain an error");
assert_eq!(
error.code.as_deref(),
Some(if rejected {
"SERVICE_SIDECAR_FAILED"
} else {
"SERVICE_INSTALL_FAILED"
})
);
if rejected {
assert!(error.detail.contains(reason));
assert!(error.detail.contains("another core is running"));
}
}
}
}
}
+2
View File
@@ -6,6 +6,7 @@ use crate::{
CoreManager,
handle::{self, Handle},
listener::MIXED_PORT_KEY,
runtime_bundle::resolve_provider_path_conflicts,
tray,
validate::CoreConfigValidator,
},
@@ -228,6 +229,7 @@ impl Config {
pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> {
let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?;
resolve_provider_path_conflicts(&mut config, &dirs::app_home_dir()?)?;
sanitize_tunnels_proxy(&mut config);
// Apply only to generated core config so the saved choice survives the next launch.
if let Some(port) = MixedPort::session_fallback() {
+133 -35
View File
@@ -1,5 +1,6 @@
use anyhow::Result;
use clash_verge_draft::DraftTransaction;
use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use sha2::{Digest as _, Sha256};
use smartstring::alias::String;
@@ -54,6 +55,7 @@ pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result
#[derive(Debug, Clone)]
pub(crate) struct DnsOverrideState {
profile_uid: String,
pub source: Option<String>,
pub enabled: bool,
requested: bool,
@@ -61,9 +63,10 @@ pub(crate) struct DnsOverrideState {
}
impl DnsOverrideState {
pub fn new(source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
pub fn new(profile_uid: &str, source: Option<String>, requested: bool, confirmation: Option<String>) -> Self {
let enabled = requested && (source.is_none() || source == confirmation);
Self {
profile_uid: profile_uid.into(),
source,
enabled,
requested,
@@ -72,24 +75,55 @@ impl DnsOverrideState {
}
fn apply_to(&self, verge: &mut IVerge) -> bool {
if self.profile_uid.is_empty() {
return false;
}
let settings = verge.dns_settings_for(&self.profile_uid);
// A later settings write must not be overwritten by an earlier runtime generation.
if verge.enable_dns_settings.unwrap_or(false) != self.requested
|| verge.dns_override_confirmation != self.confirmation
{
if (settings.enabled, settings.confirmation.as_ref()) != (self.requested, self.confirmation.as_ref()) {
return false;
}
let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation;
if self.enabled == self.requested && !clear_confirmation {
if self.enabled == self.requested
&& !clear_confirmation
&& verge.profile_dns_settings.contains_key(&self.profile_uid)
{
return false;
}
verge.enable_dns_settings = Some(self.enabled);
if clear_confirmation {
verge.dns_override_confirmation = None;
}
verge.profile_dns_settings.insert(
self.profile_uid.clone(),
ProfileDnsSettings {
enabled: self.enabled,
confirmation: if clear_confirmation {
None
} else {
self.confirmation.clone()
},
},
);
true
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ProfileDnsSettings {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirmation: Option<String>,
}
impl IVerge {
pub(crate) fn dns_settings_for(&self, profile_uid: &str) -> ProfileDnsSettings {
self.profile_dns_settings
.get(profile_uid)
.cloned()
.unwrap_or(ProfileDnsSettings {
enabled: self.enable_dns_settings.unwrap_or(false),
confirmation: None,
})
}
}
impl Config {
pub(crate) async fn sync_dns_override() -> Result<()> {
let _config_write = Self::lock_config_write().await;
@@ -116,7 +150,7 @@ impl Config {
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{DnsOverrideState, dns_override_source};
use super::{DnsOverrideState, ProfileDnsSettings, dns_override_source};
use crate::config::IVerge;
use anyhow::Result;
use serde_yaml_ng::Mapping;
@@ -152,35 +186,46 @@ mod tests {
let reordered =
mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890");
assert_eq!(source, dns_override_source("one", &reordered)?);
assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled);
assert!(DnsOverrideState::new("one", source.clone(), true, source.clone()).enabled);
assert!(!DnsOverrideState::new("two", dns_override_source("two", &original)?, true, source.clone()).enabled);
let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}");
assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled);
assert!(!DnsOverrideState::new("one", dns_override_source("one", &changed)?, true, source).enabled);
Ok(())
}
#[test]
fn dns_override_confirmation_expires_on_restart() -> Result<()> {
fn dns_override_confirmation_survives_restart_only_for_the_same_source() -> Result<()> {
let source = Some("provider-dns".into());
let confirmed = IVerge {
enable_dns_settings: Some(true),
dns_override_confirmation: source.clone(),
profile_dns_settings: [(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: source.clone(),
},
)]
.into(),
..IVerge::default()
};
assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled);
for saved in [
serde_yaml_ng::to_string(&confirmed)?,
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
let saved = serde_yaml_ng::to_string(&confirmed)?;
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source.clone(), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut restarted));
for (saved, source) in [
(saved, Some("updated".into())),
(
"enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(),
source,
),
] {
let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?;
let state = DnsOverrideState::new(
source.clone(),
restarted.enable_dns_settings.unwrap_or(false),
restarted.dns_override_confirmation.clone(),
);
let settings = restarted.dns_settings_for("one");
let state = DnsOverrideState::new("one", source, settings.enabled, settings.confirmation);
assert!(!state.enabled, "a previous session must not bypass startup protection");
assert!(state.apply_to(&mut restarted));
assert_eq!(restarted.enable_dns_settings, Some(false));
assert!(!restarted.dns_settings_for("one").enabled);
}
Ok(())
}
@@ -191,21 +236,74 @@ mod tests {
enable_dns_settings: Some(true),
..IVerge::default()
};
let state = DnsOverrideState::new(Some("provider-dns".into()), true, None);
let state = DnsOverrideState::new("one", Some("provider-dns".into()), true, None);
assert!(state.apply_to(&mut verge));
assert_eq!(verge.enable_dns_settings, Some(false));
assert!(!verge.dns_settings_for("one").enabled);
assert!(!state.apply_to(&mut verge));
let disabled = DnsOverrideState::new(None, false, None);
let disabled = DnsOverrideState::new("one", None, false, None);
assert!(!disabled.apply_to(&mut verge));
assert!(!disabled.enabled);
verge.enable_dns_settings = Some(true);
verge.dns_override_confirmation = Some("provider-dns".into());
verge.profile_dns_settings.insert(
"one".into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some("provider-dns".into()),
},
);
assert!(!state.apply_to(&mut verge));
assert_eq!(verge.enable_dns_settings, Some(true));
assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone());
let settings = verge.dns_settings_for("one");
assert!(settings.enabled);
assert_eq!(settings.confirmation.as_deref(), Some("provider-dns"));
let leaving = DnsOverrideState::new("one", None, true, settings.confirmation);
assert!(leaving.apply_to(&mut verge));
assert!(verge.dns_override_confirmation.is_none());
assert!(verge.dns_settings_for("one").confirmation.is_none());
}
#[test]
fn automatic_disable_is_saved_only_for_the_affected_profile() -> Result<()> {
let mut verge = IVerge {
enable_dns_settings: Some(true),
..IVerge::default()
};
let first = DnsOverrideState::new("one", None, true, None);
assert!(first.apply_to(&mut verge));
let protected = DnsOverrideState::new("two", Some("provider-dns".into()), true, None);
assert!(protected.apply_to(&mut verge));
assert!(verge.dns_settings_for("one").enabled);
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("unvisited").enabled);
assert_eq!(verge.enable_dns_settings, Some(true));
let restarted: IVerge = serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&verge)?)?;
assert!(restarted.dns_settings_for("one").enabled);
assert!(!restarted.dns_settings_for("two").enabled);
Ok(())
}
#[test]
fn switching_profiles_preserves_each_confirmation_until_its_source_changes() {
let mut verge = IVerge::default();
for uid in ["one", "two"] {
verge.profile_dns_settings.insert(
uid.into(),
ProfileDnsSettings {
enabled: true,
confirmation: Some(uid.into()),
},
);
}
for uid in ["one", "two", "one"] {
let settings = verge.dns_settings_for(uid);
let state = DnsOverrideState::new(uid, Some(uid.into()), settings.enabled, settings.confirmation);
assert!(state.enabled);
assert!(!state.apply_to(&mut verge));
}
let settings = verge.dns_settings_for("two");
let updated = DnsOverrideState::new("two", Some("updated".into()), settings.enabled, settings.confirmation);
assert!(updated.apply_to(&mut verge));
assert!(!verge.dns_settings_for("two").enabled);
assert!(verge.dns_settings_for("two").confirmation.is_none());
assert!(verge.dns_settings_for("one").enabled);
assert_eq!(verge.dns_settings_for("one").confirmation.as_deref(), Some("one"));
}
}
+8 -2
View File
@@ -68,8 +68,14 @@ impl ProfileDeletePlan {
return;
};
for file in self.files {
if let Err(error) = dir.join(file.as_str()).remove_if_exists().await {
logging!(warn, Type::Config, "清理已删除订阅文件失败: {file} - {error}");
let path = dir.join(file.as_str());
if let Err(error) = path.remove_if_exists().await {
logging!(
warn,
Type::Config,
"Failed to remove deleted profile file {}: {error:#}",
path.display()
);
}
}
}
+3 -4
View File
@@ -80,12 +80,11 @@ pub struct IVerge {
pub enable_bypass_check: Option<bool>,
/// enable dns settings - this controls whether dns_config.yaml is applied
/// Initial DNS override preference for profiles without a saved setting.
pub enable_dns_settings: Option<bool>,
// Force-enable confirmation is valid only for the current app session.
#[serde(skip)]
pub dns_override_confirmation: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profile_dns_settings: std::collections::BTreeMap<String, super::dns::ProfileDnsSettings>,
pub use_default_bypass: Option<bool>,
-1
View File
@@ -200,7 +200,6 @@ impl Hotkey {
HotkeyFunction::Hide => {
AsyncHandler::spawn(async move || {
feat::hide().await;
notify_event(NotificationEvent::AppHidden).await;
});
}
}
+171 -14
View File
@@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu
| ServiceStatus::NeedsReinstall
| ServiceStatus::InstallRequired
| ServiceStatus::Unavailable(_)
) | (RunningMode::Sidecar, ServiceStatus::InstallRequired)
| ServiceStatus::SidecarAllowed
) | (
RunningMode::Sidecar,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
)
)
}
@@ -185,18 +189,60 @@ where
apply_proxy().await
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
#[cfg(any(target_os = "windows", test))]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Check, CheckFuture, Fallback, FallbackFuture>(
start_service: Start,
confirm_idle: Check,
start_sidecar: Fallback,
record_unavailable: impl FnOnce(std::string::String),
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
Check: FnOnce() -> CheckFuture,
CheckFuture: std::future::Future<Output = Result<()>>,
Fallback: FnOnce(anyhow::Error) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = Result<()>>,
{
let Err(error) = start_service().await else {
return Ok(());
};
let refusal = error.downcast_ref::<crate::core::service::ServiceStartRefusal>();
let location_refused = refusal.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if refusal.is_some() && !location_refused {
return Err(error);
}
// A lost start response can hide a running core; the shared check must resolve occupancy.
if let Err(probe_error) = confirm_idle().await {
let error = error.context(format!("Sidecar fallback was not safe: {probe_error:#}"));
record_unavailable(format!("{error:#}"));
return Err(error);
}
logging!(
warn,
Type::Core,
"Service could not start the core; falling back to Sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("Sidecar fallback failed after Service failure: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate, TerminateFuture>(
clear_proxy: Clear,
terminate_sidecar: Terminate,
) -> Result<()>
where
Clear: FnOnce() -> ClearFuture,
ClearFuture: std::future::Future<Output = Result<()>>,
Terminate: FnOnce(),
Terminate: FnOnce() -> TerminateFuture,
TerminateFuture: std::future::Future<Output = Result<()>>,
{
clear_proxy().await?;
terminate_sidecar();
Ok(())
terminate_sidecar().await
}
async fn run_service_config_replacement_transition<
@@ -335,7 +381,13 @@ impl CoreManager {
if !can_allow_sidecar_for_session(&mode, &status) {
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
}
SERVICE_MANAGER.allow_sidecar_for_session()?;
#[cfg(target_os = "windows")]
if matches!(*mode, RunningMode::NotRunning) {
clash_verge_service_ipc::execution::check_sidecar_available().await?;
}
if !matches!(status, ServiceStatus::SidecarAllowed) {
SERVICE_MANAGER.allow_sidecar_for_session()?;
}
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
let prepared = async {
@@ -542,6 +594,8 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
}
if Handle::global().is_exiting() {
@@ -550,7 +604,23 @@ impl CoreManager {
}
let result = match startup {
StartupDecision::Service => self.start_core_by_service().await,
StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
|| self.start_core_by_service(),
clash_verge_service_ipc::execution::check_sidecar_available,
|error| self.start_sidecar_after_service_failure(error),
|reason| {
crate::core::runstate::RUN_STATE
.observe(crate::core::runstate::ServiceHealth::Unavailable(reason));
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()),
};
@@ -569,6 +639,50 @@ impl CoreManager {
result
}
#[cfg(target_os = "windows")]
async fn try_sidecar_after_service_unavailable(&self) -> Result<()> {
use crate::core::runstate::{RUN_STATE, ServiceHealth};
let state = RUN_STATE.state();
if state.pending.is_some() || state.op_in_flight || Handle::global().is_exiting() {
return Ok(());
}
let reason = match state.health {
ServiceHealth::Unavailable(reason) => reason,
ServiceHealth::VersionMismatch => "registered service is unavailable or incompatible".to_owned(),
_ => return Ok(()),
};
if let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
logging!(
warn,
Type::Core,
"Service unavailable ({reason}); Sidecar fallback withheld: {error:#}"
);
return Ok(());
}
self.start_sidecar_after_service_failure(anyhow::anyhow!(reason)).await
}
#[cfg(target_os = "windows")]
async fn start_sidecar_after_service_failure(&self, error: anyhow::Error) -> Result<()> {
use crate::core::runstate::RUN_STATE;
if Handle::global().is_exiting() {
return Err(error.context("application exited before Sidecar fallback"));
}
let reason = format!("{error:#}");
RUN_STATE.allow_sidecar_after_service_refusal(reason.clone())?;
logging!(warn, Type::Core, "Starting Sidecar with Service unavailable: {reason}");
let result = self.start_core_by_sidecar().await;
if result.is_err() {
SERVICE_MANAGER.withdraw_sidecar_allowance();
self.rollback_failed_start().await;
} else {
crate::core::service::notify_service_fallback(&reason);
}
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
}
#[tracing::instrument(skip_all, level = "info", fields(mode = ?*self.get_running_mode()))]
pub async fn stop_core(&self) -> Result<()> {
let _life = self.lifecycle_lock.lock().await;
@@ -604,10 +718,7 @@ impl CoreManager {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => {
self.stop_core_by_sidecar_unprepared();
Ok(())
}
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()),
}
}
@@ -1108,9 +1219,10 @@ mod tests {
let result = run_sidecar_termination_transition(
|| transition_step(&calls, "handoff-proxy-clear", fail_at),
|| {
|| async {
sidecar_alive.store(false, Ordering::Release);
calls.lock().push("handoff-sidecar-stop");
Ok(())
},
)
.await;
@@ -1368,12 +1480,18 @@ mod tests {
ServiceStatus::NeedsReinstall,
ServiceStatus::InstallRequired,
ServiceStatus::Unavailable("offline".into()),
ServiceStatus::SidecarAllowed,
];
for status in &allowed_statuses {
assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status));
if !matches!(status, ServiceStatus::InstallRequired) {
if !matches!(
status,
ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed
) {
assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
} else {
assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status));
}
}
@@ -1383,13 +1501,52 @@ mod tests {
ServiceStatus::UninstallRequired,
ServiceStatus::ReinstallRequired,
ServiceStatus::ForceReinstallRequired,
ServiceStatus::SidecarAllowed,
];
for status in &rejected_statuses {
assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status));
}
}
#[tokio::test]
async fn blocked_windows_fallback_records_failure_without_starting_sidecar() {
use crate::core::runstate::{FakeEnv, RunStateStore, ServiceHealth};
for idle in [false, true] {
let store = RunStateStore::new(FakeEnv::new());
store.observe(ServiceHealth::Ready);
let started = AtomicBool::new(false);
let result = super::run_service_start_with_sidecar_fallback(
|| async {
Err(crate::core::service::ServiceStartRefusal {
code: clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16,
core_path: "verge-mihomo.exe".into(),
message: "no administrator-approved copy is installed".into(),
}
.into())
},
|| async move {
anyhow::ensure!(idle, "another core is running");
Ok(())
},
|_| async {
started.store(true, Ordering::SeqCst);
Ok(())
},
|reason| store.observe(ServiceHealth::Unavailable(reason)),
)
.await;
assert_eq!(started.load(Ordering::SeqCst), idle);
assert_eq!(result.is_ok(), idle);
assert!(!store.state().sidecar_allowed);
if !idle {
assert!(store.state().service_needs_attention());
assert!(!store.state().service_usable());
assert!(result.is_err_and(|error| format!("{error:#}").contains("another core is running")));
}
}
}
#[test]
fn failed_service_install_can_continue_with_an_existing_sidecar() {
assert!(can_allow_sidecar_for_session(
+51
View File
@@ -74,6 +74,14 @@ impl fmt::Display for RunningMode {
}
}
/// Why the Core is not running, kept until a Core starts again.
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)]
#[serde(tag = "kind", content = "detail", rename_all = "camelCase")]
pub enum CoreFailure {
StartFailed(String),
ServiceCoreStopped(String),
}
#[derive(Debug)]
pub struct CoreManager {
/// The Run State this manager reports transitions to.
@@ -87,6 +95,8 @@ pub struct CoreManager {
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
startup_error: parking_lot::Mutex<Option<CoreFailure>>,
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
pub(crate) lifecycle_lock: tokio::sync::Mutex<()>,
@@ -114,6 +124,8 @@ impl Default for CoreManager {
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
core_readiness_state: AtomicU64::new(0),
startup_error: parking_lot::Mutex::new(None),
sidecar_exit: tokio::sync::Mutex::new(None),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
handoff_watcher_running: AtomicBool::new(false),
@@ -139,6 +151,17 @@ impl CoreManager {
self.run_state.mode_arc()
}
pub(crate) fn record_startup_error(&self, failure: CoreFailure) {
let mut startup_error = self.startup_error.lock();
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
*startup_error = Some(failure);
}
}
pub(crate) fn get_startup_error(&self) -> Option<CoreFailure> {
self.startup_error.lock().clone()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
self.state
.load()
@@ -164,7 +187,9 @@ impl CoreManager {
if previous != mode {
logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}");
}
let mut startup_error = self.startup_error.lock();
self.run_state.core_started(mode);
*startup_error = None;
}
/// The Core is no longer running, for any reason.
@@ -305,3 +330,29 @@ impl CoreManager {
}
singleton!(CoreManager, CORE_MANAGER);
#[cfg(test)]
mod startup_error_tests {
use super::{CoreFailure, CoreManager, RunningMode};
#[test]
fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() {
for mode in [RunningMode::Sidecar, RunningMode::Service] {
let manager = CoreManager::isolated();
manager.record_startup_error(CoreFailure::StartFailed("startup failed".into()));
manager.core_started(mode);
manager.core_stopped();
assert_eq!(manager.get_startup_error(), None);
}
}
#[test]
fn unresolved_startup_error_survives_repeated_reads() {
let manager = CoreManager::isolated();
let failure = CoreFailure::StartFailed("startup failed".into());
manager.record_startup_error(failure.clone());
for _ in 0..2 {
assert_eq!(manager.get_startup_error().as_ref(), Some(&failure));
}
}
}
+130 -59
View File
@@ -11,13 +11,67 @@ use crate::{
use anyhow::{Context as _, Result};
use clash_verge_logging::Type;
use log::Level;
use scopeguard::defer;
use std::path::Path;
use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
#[cfg(target_os = "windows")]
fn sidecar_config_without_tun(yaml: &str) -> Result<std::string::String> {
use serde_yaml_ng::{Mapping, Value};
let mut config: Mapping = serde_yaml_ng::from_str(yaml)?;
let tun = config
.entry(Value::from("tun"))
.or_insert_with(|| Value::Mapping(Mapping::new()));
if !tun.is_mapping() {
*tun = Value::Mapping(Mapping::new());
}
tun.as_mapping_mut()
.context("invalid Sidecar TUN configuration")?
.insert(Value::from("enable"), Value::Bool(false));
Ok(serde_yaml_ng::to_string(&config)?)
}
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
#[cfg(target_os = "windows")]
async fn retry_service_start<Start, StartFuture>(
attempts: usize,
retry_delay: std::time::Duration,
mut start: Start,
) -> Result<()>
where
Start: FnMut() -> StartFuture,
StartFuture: std::future::Future<Output = Result<()>>,
{
let mut last_error = None;
for attempt in 0..attempts {
match start().await {
Ok(()) => return Ok(()),
Err(error) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {error:#}",
attempt + 1,
attempts
);
if error
.downcast_ref::<service::ServiceStartRefusal>()
.is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code))
{
return Err(error);
}
last_error = Some(error);
if attempt + 1 < attempts {
tokio::time::sleep(retry_delay).await;
}
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
}
impl CoreManager {
/// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones.
@@ -98,12 +152,25 @@ impl CoreManager {
#[tracing::instrument(skip_all, level = "info", fields(pid = tracing::field::Empty))]
pub(super) async fn start_core_by_sidecar(&self) -> Result<()> {
self.wait_for_sidecar_exit().await?;
let execution = clash_verge_service_ipc::execution::reserve_sidecar()
.await
.inspect_err(service::record_residual_service)?;
self.core_stopped();
let sidecar_ipc = dirs::sidecar_ipc_path()?;
handle::Handle::app_handle()
.mihomo()
.update_socket_path(dirs::path_to_str(&sidecar_ipc)?.to_owned())?;
#[cfg(target_os = "windows")]
let config_file = if crate::core::runstate::RUN_STATE.state().is_admin {
Config::generate_file().await?
} else {
// Reconciliation persists the preference later; the first spawn must already have TUN off.
let yaml = sidecar_config_without_tun(&Config::runtime_config_yaml().await?)?;
Config::write_runtime_file(&yaml).await?
};
#[cfg(not(target_os = "windows"))]
let config_file = Config::generate_file().await?;
let app_handle = handle::Handle::app_handle();
let clash_core = Config::verge().await.latest_arc().get_valid_clash_core();
@@ -141,6 +208,8 @@ impl CoreManager {
config_dir.display()
)
})?;
let (terminated, termination) = tokio::sync::oneshot::channel();
*self.sidecar_exit.lock().await = Some(execution.release_after_exit(child.pid(), termination));
#[cfg(target_os = "windows")]
let job = {
match create_and_assign_sidecar_job(child.pid()) {
@@ -170,6 +239,40 @@ impl CoreManager {
let pid = child.pid();
tracing::Span::current().record("pid", pid);
// The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
// single event, so an unread channel stalls its reader threads and the core blocks on a
// full stdout pipe before it ever creates the API socket.
let core_readiness_generation = self.mark_core_ready();
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let _ = terminated.send(());
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async {
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async {
handle::Handle::mihomo().get_version().await
@@ -193,48 +296,15 @@ impl CoreManager {
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(line)
| tauri_plugin_shell::process::CommandEvent::Stderr(line) => {
let message = String::from_utf8_lossy(&line).into_owned();
Logger::global().writer_sidecar_log(Level::Error, &message);
CLASH_LOGGER.append_log(message);
}
tauri_plugin_shell::process::CommandEvent::Terminated(term) => {
let manager = Self::global();
let _ = manager.invalidate_core_readiness_if(core_readiness_generation);
let message = if let Some(code) = term.code {
format!("Process terminated with code: {}", code)
} else if let Some(signal) = term.signal {
format!("Process terminated by signal: {}", signal)
} else {
String::from("Process terminated")
};
Logger::global().writer_sidecar_log(Level::Info, &message);
CLASH_LOGGER.clear_logs();
manager.clear_terminated_sidecar(pid).await;
break;
}
_ => {}
}
}
});
Ok(())
}
/// Terminates the sidecar after its caller has successfully cleared the
/// system proxy.
pub(super) fn stop_core_by_sidecar_unprepared(&self) {
defer! {
self.core_stopped();
}
pub(super) async fn stop_core_by_sidecar_unprepared(&self) -> Result<()> {
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
@@ -250,6 +320,22 @@ impl CoreManager {
logging!(warn, Type::Core, "failed to terminate sidecar PID {pid}: {error:#}");
}
}
let result = self.wait_for_sidecar_exit().await;
self.core_stopped();
result
}
async fn wait_for_sidecar_exit(&self) -> Result<()> {
let mut exit = self.sidecar_exit.lock().await;
if let Some(task) = exit.as_mut() {
tokio::time::timeout(std::time::Duration::from_secs(5), task)
.await
.context("Sidecar has not exited; core handoff is blocked")?
.context("Sidecar exit monitoring failed")?;
exit.take();
}
drop(exit);
Ok(())
}
pub(super) async fn start_core_by_service(&self) -> Result<()> {
@@ -269,30 +355,15 @@ impl CoreManager {
#[cfg(target_os = "windows")]
{
use crate::constants::timing;
let mut last_err = None;
for attempt in 0..timing::SERVICE_START_RETRIES {
match service::run_core_by_service(config_file).await {
Ok(()) => {
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY);
return Ok(());
}
Err(e) => {
logging!(
warn,
Type::Core,
"service start attempt {}/{} failed: {e:#}",
attempt + 1,
timing::SERVICE_START_RETRIES
);
last_err = Some(e);
tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await;
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed")))
retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
service::run_core_by_service(config_file)
})
.await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
Ok(())
}
#[cfg(not(target_os = "windows"))]
+1 -1
View File
@@ -12,7 +12,7 @@ pub(crate) mod owner_identity;
pub mod proxy_control;
pub mod proxy_view;
pub mod runstate;
mod runtime_bundle;
pub(crate) mod runtime_bundle;
pub mod service;
pub mod sysopt;
pub mod timer;
+3 -6
View File
@@ -392,13 +392,10 @@ mod windows_owner {
}
pub(super) fn repair_root_owner(root: &Path) -> Result<()> {
// Elevated Windows processes can create directories owned by Administrators.
std::fs::create_dir_all(root).context("failed to create the application data root before owner repair")?;
// The Service canonicalizes before checking, so resolve junctions to the same object.
let root = match std::fs::canonicalize(root) {
Ok(path) => path,
// Nothing to repair: this process creates the root and owns it by construction.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error).context("failed to canonicalize the application data root"),
};
let root = std::fs::canonicalize(root).context("failed to canonicalize the application data root")?;
let root = root.as_path();
let sid = current_sid()?;
+46 -2
View File
@@ -27,8 +27,42 @@ pub struct RealEnv;
impl RunStateEnv for RealEnv {
async fn probe_service_version(&self) -> Result<ServiceVersionReply> {
// The IPC client would otherwise back off for about two minutes before failing.
#[cfg(windows)]
if tokio::task::spawn_blocking(crate::core::service::service_stopped)
.await
.context("service status probe did not finish")??
{
anyhow::bail!("the Windows service is not running");
}
let response = clash_verge_service_ipc::get_version().await?;
let core = if response.code == 0
&& response.data.as_ref().is_some_and(|info| {
info.supports_client(
clash_verge_service_ipc::ProtocolVersion::current(),
clash_verge_service_ipc::MIN_REQUIRED_SERVICE_REVISION,
)
}) {
let name = format!(
"{}{}",
crate::config::Config::verge().await.latest_arc().get_valid_clash_core(),
std::env::consts::EXE_SUFFIX
);
let status = clash_verge_service_ipc::inspect_installation(&[clash_verge_service_ipc::CoreRequirement {
name: name.clone(),
sha256: None,
}])
.await?;
status
.cores
.into_iter()
.find(|core| core.name == name)
.map(|core| core.availability)
} else {
None
};
Ok(ServiceVersionReply {
core,
code: response.code,
message: response.message,
protocol: response.data,
@@ -36,9 +70,17 @@ impl RunStateEnv for RealEnv {
}
async fn trusted_install_evidence(&self) -> Result<bool> {
tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
let registered = tokio::task::spawn_blocking(crate::core::service::trusted_service_evidence)
.await
.context("service registration probe did not finish")?
.context("service registration probe did not finish")??;
// A helper that outlived its registration is a broken Service, not an absent one.
#[cfg(unix)]
if !registered && let Err(error) = clash_verge_service_ipc::execution::check_sidecar_available().await {
return Ok(error
.downcast_ref::<clash_verge_service_ipc::execution::ResidualServiceError>()
.is_some());
}
Ok(registered)
}
fn is_elevated(&self) -> bool {
@@ -119,6 +161,7 @@ mod fake {
#[must_use]
pub fn service_ready(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -128,6 +171,7 @@ mod fake {
#[must_use]
pub fn service_version_mismatch(self) -> Self {
self.with_evidence(true).always_replying(Ok(ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
+25 -2
View File
@@ -28,7 +28,7 @@ pub use env::FakeEnv;
pub use env::{RealEnv, RunStateEnv};
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
use crate::core::manager::RunningMode;
use health::StoredService;
@@ -152,6 +152,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
self.observe(ServiceHealth::Ready);
Ok(self.state())
}
ServiceVersionCheck::CoreUnavailable(error) => {
self.observe(ServiceHealth::Unavailable(error.clone()));
bail!(error)
}
ServiceVersionCheck::NeedsReinstall(error) => {
self.observe(ServiceHealth::VersionMismatch);
bail!(error)
@@ -178,7 +182,10 @@ impl<E: RunStateEnv> RunStateStore<E> {
}
match self.env.probe_service_version().await {
Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""),
Ok(reply) => match classify_service_version_reply(&reply) {
ServiceVersionCheck::CoreUnavailable(reason) => ServiceHealth::Unavailable(reason),
_ => classify_service_health(probe_outcome(&reply), has_marker, ""),
},
Err(error) => {
logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}");
classify_service_health(
@@ -323,6 +330,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(())
}
#[cfg(target_os = "windows")]
pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> {
let mut state = self.service.lock();
if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() {
bail!("cannot fall back while a service operation is pending");
}
// Publish the refusal and allowance together so it never asks the UI for a repair.
state.service.health = ServiceHealth::Unavailable(reason);
state.service.allow_sidecar();
state.bump();
drop(state);
self.announce();
Ok(())
}
/// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock();
@@ -472,6 +494,7 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()),
+50 -13
View File
@@ -4,7 +4,9 @@
//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC,
//! systemd, SCM or launchd.
use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion};
use clash_verge_service_ipc::{
MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion, VERSION as SERVICE_VERSION,
};
use super::health::ServiceHealth;
@@ -14,6 +16,7 @@ pub struct ServiceVersionReply {
pub code: u16,
pub message: String,
pub protocol: Option<ProtocolInfo>,
pub core: Option<clash_verge_service_ipc::CoreAvailability>,
}
/// The verdict on a [`ServiceVersionReply`].
@@ -21,8 +24,12 @@ pub struct ServiceVersionReply {
pub enum ServiceVersionCheck {
Ready,
NeedsReinstall(String),
CoreUnavailable(String),
}
/// Leads the Unavailable reason when the Service refused the approved core itself.
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
@@ -32,22 +39,30 @@ pub enum CurrentServiceProbe {
Unavailable,
}
/// Decide whether the Service we reached speaks a protocol this client can use.
/// Require the bundled Service version as well as a usable protocol.
pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck {
let client = ProtocolVersion::current();
if reply.code == 0
&& reply
.protocol
.as_ref()
.is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION))
&& reply.protocol.as_ref().is_some_and(|info| {
info.build_version == SERVICE_VERSION && info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)
})
{
return ServiceVersionCheck::Ready;
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
),
};
}
let detail = if reply.code == 0 {
match reply.protocol.as_ref() {
Some(info) => format!(
"client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
"client requires service version {} with epoch {} revision >= {}, service reports epoch {} revision {} (build {})",
SERVICE_VERSION,
client.epoch,
MIN_REQUIRED_SERVICE_REVISION,
info.protocol.epoch,
@@ -63,16 +78,16 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
)
};
ServiceVersionCheck::NeedsReinstall(format!(
"Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue"
"Service helper version or protocol mismatch: {detail}. Choose Reinstall to uninstall the existing service and install the required version"
))
}
/// Classify a version reply into the probe outcome used by [`classify_service_health`].
pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe {
if classify_service_version_reply(reply) == ServiceVersionCheck::Ready {
CurrentServiceProbe::Ready
} else {
CurrentServiceProbe::VersionMismatch
match classify_service_version_reply(reply) {
ServiceVersionCheck::Ready => CurrentServiceProbe::Ready,
ServiceVersionCheck::NeedsReinstall(_) => CurrentServiceProbe::VersionMismatch,
ServiceVersionCheck::CoreUnavailable(_) => CurrentServiceProbe::Unavailable,
}
}
@@ -102,6 +117,7 @@ mod tests {
fn ready_reply() -> ServiceVersionReply {
ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -117,9 +133,26 @@ mod tests {
assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready);
}
#[test]
fn compatible_service_with_missing_core_is_unavailable() {
for core in [
None,
Some(clash_verge_service_ipc::CoreAvailability::Missing),
Some(clash_verge_service_ipc::CoreAvailability::DigestMismatch),
] {
let reply = ServiceVersionReply { core, ..ready_reply() };
assert!(matches!(
classify_service_version_reply(&reply),
ServiceVersionCheck::CoreUnavailable(_)
));
assert_eq!(probe_outcome(&reply), CurrentServiceProbe::Unavailable);
}
}
#[test]
fn a_reply_without_protocol_information_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: None,
@@ -137,6 +170,7 @@ mod tests {
#[test]
fn a_nonzero_code_needs_reinstall() {
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 7,
message: "nope".to_owned(),
protocol: Some(ProtocolInfo::current()),
@@ -152,6 +186,7 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.epoch = info.protocol.epoch.wrapping_add(1);
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -172,6 +207,7 @@ mod tests {
let mut info = ProtocolInfo::current();
info.protocol.revision = revision;
let reply = ServiceVersionReply {
core: Some(clash_verge_service_ipc::CoreAvailability::Ready),
code: 0,
message: "ok".to_owned(),
protocol: Some(info),
@@ -190,6 +226,7 @@ mod tests {
for protocol in [None, Some(old_epoch)] {
for code in [0, 1] {
let reply = ServiceVersionReply {
core: None,
code,
message: "unsupported command".to_owned(),
protocol: protocol.clone(),
+176 -2
View File
@@ -2,11 +2,13 @@ use anyhow::{Context as _, Result, bail};
use clash_verge_logging::{Type, logging};
use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle};
use serde_yaml_ng::{Mapping, Value};
use std::collections::HashSet;
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path, PathBuf};
pub(crate) const GEO_ASSETS: &[&str] = &[
"Country.mmdb",
"ASN.mmdb",
"geoip.dat",
"geosite.dat",
"geoip.metadb",
@@ -53,6 +55,94 @@ pub(crate) fn remote_providers_of(config: &Mapping, config_root: &Path) -> Resul
Ok(collect_runtime_parts(Value::Mapping(config.clone()), config_root)?.remote_providers)
}
#[derive(Default)]
struct ProviderPathOwners {
remote: BTreeMap<String, Vec<(&'static str, Value)>>,
local: bool,
}
pub(crate) fn resolve_provider_path_conflicts(config: &mut Mapping, config_root: &Path) -> Result<()> {
let config_root = std::fs::canonicalize(config_root)?;
let mut paths = BTreeMap::<String, ProviderPathOwners>::new();
let mut reserved: HashSet<String> = GEO_ASSETS.iter().map(|name| (*name).to_owned()).collect();
for section in ["proxy-providers", "rule-providers"] {
let Some(providers) = config.get(section).and_then(Value::as_mapping) else {
continue;
};
for (name, provider) in providers {
let Some(raw_path) = provider.get("path").and_then(Value::as_str) else {
continue;
};
let is_remote = provider.get("type").and_then(Value::as_str) == Some("http");
let destination = if is_remote {
provider_destination(&config_root, raw_path)
} else {
local_provider_source(&config_root, raw_path)
.and_then(|source| destination_below_root(&config_root, &source))
.or_else(|_| provider_destination(&config_root, raw_path))
};
let Ok(destination) = destination else {
continue;
};
reserved.insert(destination.clone());
let owners = paths.entry(destination).or_default();
match (is_remote, provider.get("url").and_then(Value::as_str)) {
(true, Some(url)) => owners
.remote
.entry(url.to_owned())
.or_default()
.push((section, name.clone())),
_ => owners.local = true,
}
}
}
for (destination, owners) in paths {
if owners.local || owners.remote.len() < 2 {
continue;
}
// Allocate every source in a conflict group, so declaration order cannot select a cache's owner.
for (url, providers) in owners.remote {
let replacement = allocate_provider_destination(&destination, &url, &mut reserved);
for (section, name) in providers {
config[section][&name]["path"] = Value::String(replacement.clone());
logging!(
warn,
Type::Config,
"provider {section}/{name:?} shared cache {destination:?} with another source; using {replacement:?}"
);
}
}
}
Ok(())
}
fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut HashSet<String>) -> String {
let mut digest = Sha256::new();
digest.update(destination.as_bytes());
digest.update([0]);
digest.update(url.as_bytes());
let digest: String = digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
let path = Path::new(destination);
let extension = path
.extension()
.map(|ext| format!(".{}", ext.to_string_lossy()))
.unwrap_or_default();
let mut suffix = String::new();
let mut sequence = 0;
loop {
let candidate = path
.with_file_name(format!("cvr-{digest}{suffix}{extension}"))
.to_string_lossy()
.replace('\\', "/");
if reserved.insert(candidate.clone()) {
return candidate;
}
sequence += 1;
suffix = format!("-{sequence}");
}
}
fn collect_runtime_parts(mut config: Value, config_root: &Path) -> Result<RuntimeParts> {
let config_root = std::fs::canonicalize(config_root)?;
let mut assets = Vec::new();
@@ -254,7 +344,91 @@ fn normalized_destination(relative: &Path) -> Result<String> {
#[cfg(test)]
mod tests {
use super::collect_runtime_bundle;
use super::{collect_runtime_bundle, remote_providers_of, resolve_provider_path_conflicts};
#[tokio::test]
async fn conflicting_sources_get_separate_paths_in_config_bundle_and_readback() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!("clash-verge-provider-paths-{}", std::process::id()));
std::fs::create_dir_all(&root)?;
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n AppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: ./providers/rule/AppleDev.yaml\n mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
)?;
let original = config.clone();
let root = std::fs::canonicalize(root)?;
let mut reordered = config.clone();
reordered["rule-providers"] = serde_yaml_ng::from_str(&format!(
"mirror:\n type: http\n url: https://two.example/AppleDev.yaml\n path: {}\nAppleDev:\n type: http\n url: https://one.example/AppleDev.yaml\n path: providers/rule/AppleDev.yaml\n",
root.join("providers/rule/AppleDev.yaml").display(),
))?;
resolve_provider_path_conflicts(&mut config, &root)?;
let config_file = root.join("config.yaml");
std::fs::write(&config_file, serde_yaml_ng::to_string(&config)?)?;
let bundle = collect_runtime_bundle(&config_file, &root.join("mihomo")).await?;
let paths = &config["rule-providers"];
assert_ne!(paths["AppleDev"]["path"], paths["mirror"]["path"]);
resolve_provider_path_conflicts(&mut reordered, &root)?;
assert_eq!(
config, reordered,
"path allocation must not depend on declaration order or path spelling"
);
let resolved = config.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_eq!(
config, resolved,
"resolving an already generated config must be idempotent"
);
assert_eq!(serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&bundle.yaml)?, config);
let readback = remote_providers_of(&config, &root)?;
assert_eq!(bundle.remote_providers.len(), 2);
for declared in readback {
assert!(bundle.remote_providers.contains(&declared.provider));
assert_eq!(
config[declared.section][&declared.name]["path"],
declared.provider.destination
);
assert_eq!(
config[declared.section][&declared.name]["url"],
original[declared.section][&declared.name]["url"]
);
}
std::fs::remove_dir_all(root)?;
Ok(())
}
#[test]
fn allocated_provider_paths_do_not_claim_another_declared_path() -> anyhow::Result<()> {
let root = std::env::temp_dir();
let original: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"proxy-providers:\n a:\n type: http\n url: https://one.example/p.yaml\n path: ./providers/shared.yaml\nrule-providers:\n b:\n type: http\n url: https://two.example/r.yaml\n path: ./providers/shared.yaml\n",
)?;
let mut config = original.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
let allocated = config["proxy-providers"]["a"]["path"].clone();
let mut config = original;
config["rule-providers"]["reserved"] = serde_yaml_ng::from_str("type: file\npath: placeholder")?;
config["rule-providers"]["reserved"]["path"] = allocated.clone();
resolve_provider_path_conflicts(&mut config, &root)?;
assert_ne!(config["proxy-providers"]["a"]["path"], allocated);
assert_ne!(
config["proxy-providers"]["a"]["path"],
config["rule-providers"]["b"]["path"]
);
assert_eq!(config["rule-providers"]["reserved"]["path"], allocated);
Ok(())
}
#[test]
fn provider_path_resolution_leaves_shared_sources_and_local_files_unchanged() -> anyhow::Result<()> {
let mut config: serde_yaml_ng::Mapping = serde_yaml_ng::from_str(
"rule-providers:\n a:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n b:\n type: http\n url: https://one.example/r.yaml\n path: ./rules/shared.yaml\n local:\n type: file\n path: ./rules/local.yaml\n remote:\n type: http\n url: https://two.example/r.yaml\n path: ./rules/local.yaml\n mirror:\n type: http\n url: https://three.example/r.yaml\n path: ./rules/local.yaml\n",
)?;
let original = config.clone();
resolve_provider_path_conflicts(&mut config, &std::env::temp_dir())?;
assert_eq!(config, original);
Ok(())
}
#[tokio::test]
async fn collects_only_local_providers_and_existing_geo_assets() -> anyhow::Result<()> {
File diff suppressed because it is too large Load Diff
+29 -14
View File
@@ -1,5 +1,5 @@
use crate::{config::Config, singleton, utils::dirs};
use anyhow::Result;
use anyhow::{Context as _, Result};
use chrono::Utc;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
@@ -47,17 +47,20 @@ impl SilentUpdater {
fn write_cache(bytes: &[u8], version: &str) -> Result<()> {
let cache_dir = Self::cache_dir()?;
std::fs::create_dir_all(&cache_dir)?;
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("failed to create update cache directory {}", cache_dir.display()))?;
let bin_path = cache_dir.join("pending_update.bin");
std::fs::write(&bin_path, bytes)?;
std::fs::write(&bin_path, bytes)
.with_context(|| format!("failed to write update cache {}", bin_path.display()))?;
let meta = UpdateCacheMeta {
version: version.to_string(),
downloaded_at: Utc::now().to_rfc3339(),
};
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)?)
.with_context(|| format!("failed to write update metadata {}", meta_path.display()))?;
logging!(
info,
@@ -71,7 +74,7 @@ impl SilentUpdater {
fn read_cache_bytes() -> Result<Vec<u8>> {
let bin_path = Self::cache_dir()?.join("pending_update.bin");
Ok(std::fs::read(bin_path)?)
std::fs::read(&bin_path).with_context(|| format!("failed to read update cache {}", bin_path.display()))
}
fn read_cache_meta() -> Result<UpdateCacheMeta> {
@@ -85,7 +88,12 @@ impl SilentUpdater {
&& cache_dir.exists()
{
if let Err(e) = std::fs::remove_dir_all(&cache_dir) {
logging!(warn, Type::System, "Failed to delete update cache: {e}");
logging!(
warn,
Type::System,
"Failed to delete update cache {}: {e}",
cache_dir.display()
);
} else {
logging!(info, Type::System, "Update cache deleted");
}
@@ -93,15 +101,22 @@ impl SilentUpdater {
}
}
/// Compares numeric version components after stripping `v` and prerelease suffixes.
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false;
};
!build.is_empty() && !base.contains('-') && base == remote.trim_start_matches('v')
}
/// Compares numeric version components after stripping version suffixes.
fn version_lte(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u64> {
v.trim_start_matches('v')
.split(['-', '+'])
.next()
.unwrap_or("0")
.split('.')
.filter_map(|part| {
let numeric = part.split('-').next().unwrap_or("0");
numeric.parse::<u64>().ok()
})
.filter_map(|part| part.parse::<u64>().ok())
.collect()
};
@@ -144,7 +159,7 @@ impl SilentUpdater {
let cached_version = &meta.version;
if version_lte(cached_version, current_version) {
if !is_build_to_stable(current_version, cached_version) && version_lte(cached_version, current_version) {
logging!(
info,
Type::System,
@@ -176,7 +191,7 @@ impl SilentUpdater {
logging!(
warn,
Type::System,
"Failed to read cached update bytes: {e}, cleaning up"
"Failed to read cached update bytes: {e:#}, cleaning up"
);
Self::delete_cache();
return false;
@@ -456,7 +471,7 @@ impl SilentUpdater {
.await?;
if let Err(e) = Self::write_cache(&bytes, &version) {
logging!(warn, Type::System, "Silent updater: failed to write cache: {e}");
logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}");
}
*self.pending_bytes.write() = Some(bytes);
+4 -4
View File
@@ -226,7 +226,7 @@ impl CoreConfigValidator {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read file: {err}").into();
logging!(error, Type::Validate, "无法读取文件: {}", error_msg);
logging!(error, Type::Validate, "Failed to read file {config_path}: {err}");
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
@@ -238,7 +238,7 @@ impl CoreConfigValidator {
}
Err(err) => {
let error_msg: String = format!("YAML syntax error: {err}").into();
logging!(error, Type::Validate, "YAML语法错误: {}", error_msg);
logging!(error, Type::Validate, "YAML syntax error in {config_path}: {err}");
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
@@ -249,7 +249,7 @@ impl CoreConfigValidator {
Ok(content) => content,
Err(err) => {
let error_msg: String = format!("Failed to read script file: {err}").into();
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
logging!(warn, Type::Validate, "Failed to read script file {path}: {err}");
return Ok(ValidationOutcome::invalid_from_message(error_msg));
}
};
@@ -291,7 +291,7 @@ impl CoreConfigValidator {
}
Err(err) => {
let error_msg: String = format!("Script syntax error: {err}").into();
logging!(warn, Type::Validate, "脚本语法错误: {}", err);
logging!(warn, Type::Validate, "Script syntax error in {path}: {err}");
Ok(ValidationOutcome::invalid_from_message(error_msg))
}
}
+45 -17
View File
@@ -1,5 +1,3 @@
use clash_verge_logging::{Type, logging};
use super::field::use_lowercase;
use serde_yaml_ng::{self, Mapping, Value};
@@ -18,20 +16,19 @@ fn deep_merge(a: &mut Value, b: Value) {
}
}
pub fn use_merge(merge: &Mapping, config: Mapping) -> Mapping {
let mut config = Value::from(config);
let merge = use_lowercase(merge);
deep_merge(&mut config, Value::from(merge));
config.as_mapping().cloned().unwrap_or_else(|| {
logging!(
error,
Type::Core,
"Failed to convert merged config to mapping, using empty mapping"
);
Mapping::new()
})
pub fn use_merge(merge: &Mapping, mut config: Mapping) -> Mapping {
for (key, value) in use_lowercase(merge) {
match (config.get_mut(&key), value) {
(Some(Value::Mapping(existing)), Value::Mapping(overlay)) if key.as_str() == Some("dns") => {
existing.extend(overlay);
}
(Some(existing), value) if key.as_str() != Some("hosts") => deep_merge(existing, value),
(_, value) => {
config.insert(key, value);
}
}
}
config
}
#[test]
@@ -54,18 +51,49 @@ fn test_merge() -> anyhow::Result<()> {
enable: true
dns:
enable: true
nameserver-policy:
new.example: 1.1.1.1
fallback-filter:
geoip: false
hosts:
new.example: 192.0.2.2
";
let config = r"
rules:
- aaaaa
script1: test
tun:
mtu: 1500
dns:
nameserver: [9.9.9.9]
nameserver-policy:
old.example: 8.8.8.8
fallback-filter:
geoip: true
domain: [old.example]
hosts:
old.example: 192.0.2.1
";
let merge = serde_yaml_ng::from_str::<Mapping>(merge)?;
let config = serde_yaml_ng::from_str::<Mapping>(config)?;
let _ = serde_yaml_ng::to_string(&use_merge(&merge, config))?;
let result = use_merge(&merge, config);
assert_eq!(result["dns"]["nameserver-policy"], merge["dns"]["nameserver-policy"]);
assert_eq!(result["dns"]["fallback-filter"], merge["dns"]["fallback-filter"]);
assert_eq!(result["hosts"], merge["hosts"]);
assert_eq!(result["dns"]["nameserver"], serde_yaml_ng::to_value(["9.9.9.9"])?);
assert_eq!(result["tun"]["mtu"], Value::from(1500));
assert_eq!(result["rules"], merge["rules"]);
assert_eq!(result["script1"], Value::from("test"));
let cleared = serde_yaml_ng::from_str::<Mapping>(
"dns: {enable: false, nameserver-policy: {}, fallback-filter: {}, nameserver: []}\nhosts: {}",
)?;
let result = use_merge(&cleared, result);
assert_eq!(result["dns"], cleared["dns"]);
assert_eq!(result["hosts"], cleared["hosts"]);
Ok(())
}
+322 -97
View File
@@ -14,6 +14,7 @@ use self::{
tun::use_tun,
};
use crate::config::dns::{DnsOverrideState, dns_override_source};
use crate::core::handle::Handle;
use crate::utils::dirs;
use crate::{
config::{Config, IProfiles, IVerge, PrfItem},
@@ -22,6 +23,7 @@ use crate::{
};
use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
@@ -103,7 +105,7 @@ async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce
}
}
async fn get_config_values() -> ConfigValues {
async fn get_config_values(profile_uid: &str) -> ConfigValues {
let clash = Config::clash().await;
let clash_arc = clash.latest_arc();
let clash_config = clash_arc.0.clone();
@@ -118,12 +120,12 @@ async fn get_config_values() -> ConfigValues {
ref enable_builtin_enhanced,
ref verge_socks_enabled,
ref verge_http_enabled,
ref enable_dns_settings,
ref enable_external_controller,
..
} = **verge_arc;
let enable_external_controller = enable_external_controller.unwrap_or(false);
let dns_override_confirmation = verge_arc.dns_override_confirmation.clone();
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
@@ -131,7 +133,7 @@ async fn get_config_values() -> ConfigValues {
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
enable_dns_settings.unwrap_or(false),
dns_settings.enabled,
);
#[cfg(not(target_os = "windows"))]
@@ -247,16 +249,33 @@ async fn process_global_items(
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.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(global_merge.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = global_script.data {
let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
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(global_script.uid, logs);
}
@@ -284,8 +303,7 @@ fn process_seq_items(
config
}
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
/// App-owned control-plane keys; DNS settings are captured separately.
const CONTROL_PLANE_KEYS: &[&str] = &[
"external-controller",
#[cfg(unix)]
@@ -308,32 +326,117 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
"unified-delay",
];
/// The fields the app owns, held across the stages that apply the user's manual overrides.
///
/// Capturing and restoring are one value rather than two calls because the order is a
/// correctness requirement, not a style: capture must happen after the app has finished
/// deriving these fields and before any override runs, and restore must happen after every
/// override. As four separate calls that contract lived only in comments.
/// App-owned fields: captured after the app derives them, enforced after every override.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
dns_ipv6: Option<Value>,
dns: Mapping,
hosts: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
fn capture(config: &Mapping, gui_tun_keys: &[Value], mut dns_settings: Mapping) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
dns: take_mapping(&mut dns_settings, "dns"),
hosts: dns_settings.remove("hosts"),
}
}
fn enforce(self, config: Mapping) -> Mapping {
let config = enforce_control_plane(config, self.control_plane);
let config = enforce_tun(config, self.tun);
enforce_dns_ipv6(config, self.dns_ipv6)
let mut config = enforce_tun(config, self.tun);
if !self.dns.is_empty() {
let mut dns = take_mapping(&mut config, "dns");
dns.extend(self.dns);
config.insert("dns".into(), dns.into());
}
if let Some(hosts) = self.hosts {
config.insert("hosts".into(), hosts);
}
config
}
/// The owned values as `config` holds them now, for diffing one override.
fn current(&self, config: &Mapping) -> Self {
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
let dns = config.get("dns").and_then(Value::as_mapping);
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, &tun_keys),
dns: self
.dns
.keys()
.filter_map(|key| Some((key.clone(), dns?.get(key)?.clone())))
.collect(),
hosts: self.hosts.as_ref().and_then(|_| config.get("hosts")).cloned(),
}
}
/// Owned keys an override moved off the app value; `enforce` discards those writes.
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
let mut keys = Vec::new();
for &key in CONTROL_PLANE_KEYS {
if before.control_plane.get(key) != after.control_plane.get(key)
&& after.control_plane.get(key) != self.control_plane.get(key)
{
keys.push(key.into());
}
}
for key in self.tun.keys() {
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
}
}
for key in self.dns.keys() {
if before.dns.get(key) != after.dns.get(key) && after.dns.get(key) != self.dns.get(key) {
keys.push(format!("dns.{}", key.as_str().unwrap_or_default()).into());
}
}
if self.hosts.is_some() && before.hosts != after.hosts && after.hosts != self.hosts {
keys.push("hosts".into());
}
keys
}
}
fn discarded_note(key: String) -> (String, String) {
(
"warn".into(),
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
)
}
struct DiscardedKeysNotice {
pending: Option<String>,
last: Vec<String>,
}
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
pending: None,
last: Vec::new(),
});
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
DISCARDED_KEYS_NOTICE.lock().pending.take()
}
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
fn notify_discarded_keys(keys: Vec<String>) {
let mut notice = DISCARDED_KEYS_NOTICE.lock();
if notice.last == keys {
return;
}
let should_notify = !keys.is_empty();
if should_notify {
notice.pending = Some(keys.join(", ").into());
}
notice.last = keys;
drop(notice);
if should_notify {
Handle::notice_message("enhance::discarded_keys", "");
}
}
@@ -401,21 +504,6 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
config
}
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> Mapping {
if let Some(dns_ipv6) = dns_ipv6
&& let Some(Value::Mapping(dns)) = config.get_mut("dns")
{
dns.insert(Value::from("ipv6"), dns_ipv6);
}
config
}
fn is_loopback_bind_address(addr: &str) -> bool {
let addr = addr.trim();
let addr = addr
@@ -465,27 +553,40 @@ async fn process_profile_items(
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 (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await;
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)
}
/// Merge the Application Merge Config over the profile's own configuration.
///
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
/// out of the global config from inside its loop, which made a stage that looks pure depend
/// on process state and left it untestable.
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
@@ -683,9 +784,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
config
}
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
@@ -708,45 +807,78 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
}
}
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
fn is_set(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(on) => *on,
Value::String(text) => !text.trim().is_empty(),
Value::Sequence(items) => !items.is_empty(),
Value::Mapping(map) => !map.is_empty(),
Value::Number(_) | Value::Tagged(_) => true,
}
}
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
config
.get_mut(key)
.and_then(Value::as_mapping_mut)
.map(std::mem::take)
.unwrap_or_default()
}
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
let mut applied = Mapping::new();
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
&& !hosts.is_empty()
{
applied.insert("hosts".into(), hosts.clone().into());
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
// Legacy layout: no `dns` root.
let dns_override = match dns_config.remove("dns") {
Some(Value::Mapping(dns)) => Some(dns),
Some(_) => None,
None => Some(dns_config),
};
if let Some(mut dns_override) = dns_override {
dns_override.retain(|_, value| is_set(value));
if !dns_override.is_empty() {
applied.insert("dns".into(), dns_override.clone().into());
}
let mut dns = take_mapping(&mut config, "dns");
dns.extend(dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
(config, applied)
}
/// Returns the merged config and the non-empty fields owned by the DNS page.
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, Mapping) {
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
&& let Ok(dns_config) = serde_yaml_ng::from_str::<Mapping>(&dns_yaml)
{
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
logging!(debug, Type::Core, "apply hosts configuration");
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
let mut dns_mapping = dns_mapping.clone();
ensure_fake_ip_range6(&mut dns_mapping);
config.insert("dns".into(), dns_mapping.into());
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
}
} else {
let mut dns_config = dns_config;
ensure_fake_ip_range6(&mut dns_config);
config.insert("dns".into(), dns_config.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
return merge_dns_config(config, dns_config);
}
}
config
(config, Mapping::new())
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
pub async fn enhance(
profiles: &IProfiles,
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
let cfg_vals = get_config_values().await;
let profile_uid = profiles.current.as_deref().unwrap_or_default();
let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues {
clash_config,
clash_core,
@@ -765,7 +897,8 @@ pub async fn enhance(
let profile = collect_profile_items(profiles).await?;
let dns_override = DnsOverrideState::new(
dns_override_source(profiles.current.as_deref().unwrap_or_default(), &profile.config)?,
profile_uid,
dns_override_source(profile_uid, &profile.config)?,
enable_dns_settings,
dns_override_confirmation,
);
@@ -800,9 +933,9 @@ pub async fn enhance(
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let config = 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, enable_dns_settings);
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, dns_settings);
let (config, exists_keys, result_map) = process_global_items(
config,
@@ -811,12 +944,22 @@ pub async fn enhance(
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, result_map) =
process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await;
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
merge_item,
script_item,
&profile_name,
&authoritative,
)
.await;
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
let config = authoritative.enforce(config);
let config = ensure_lan_bind_address(config);
@@ -864,8 +1007,7 @@ mod fake_ip_tests {
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// The reason this is not just a `contains_key` check: YAML edited by hand often
// leaves the key present with nothing after the colon.
// Hand-edited YAML may leave the key present but empty.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
@@ -1004,8 +1146,7 @@ mod use_tun_tests {
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
// resolver is restored, which is not part of the configuration.
// TUN off must not rewrite the DNS the profile chose.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
@@ -1038,8 +1179,7 @@ mod use_sort_tests {
#[test]
fn the_bulky_list_fields_are_written_last() {
// These are the fields that make a config file unreadable from the top, so they are
// pushed below the settings a human actually scans for.
// Bulky list fields go last so the top of the file stays readable.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
@@ -1129,8 +1269,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not just skipped: the profile may have asked for the port itself, and a
// listener the user switched off must not be opened by the profile's own value.
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
@@ -1152,8 +1291,7 @@ mod merge_default_config_tests {
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
// listen anyway, which is the opposite of what switching it off means.
// Blanked, not removed: an absent key makes mihomo listen on its default.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
@@ -1223,7 +1361,7 @@ mod authoritative_field_tests {
#[test]
fn an_override_cannot_change_a_field_the_app_owns() {
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
let result = authoritative.enforce(overridden);
@@ -1236,7 +1374,7 @@ mod authoritative_field_tests {
fn an_override_cannot_introduce_a_field_the_app_left_out() {
// The app decided not to expose the external controller; a profile must not re-add it.
let derived = Mapping::new();
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
let result = authoritative.enforce(overridden);
@@ -1247,7 +1385,7 @@ mod authoritative_field_tests {
#[test]
fn fields_the_app_does_not_own_survive_an_override() {
let derived = config_with(&[("mode", Value::from("rule"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let authoritative = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
let result = authoritative.enforce(overridden);
@@ -1259,7 +1397,7 @@ mod authoritative_field_tests {
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let owned = AuthoritativeFields::capture(&derived, &[], true);
let owned = AuthoritativeFields::capture(&derived, &[], derived.clone());
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
@@ -1267,7 +1405,7 @@ mod authoritative_field_tests {
"with the DNS page on, the app's value wins"
);
let unowned = AuthoritativeFields::capture(&derived, &[], false);
let unowned = AuthoritativeFields::capture(&derived, &[], Mapping::new());
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
@@ -1277,14 +1415,13 @@ mod authoritative_field_tests {
}
#[test]
fn restoring_dns_ipv6_never_invents_a_dns_block() {
fn restoring_dns_settings_reinstates_a_removed_dns_block() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], true);
let authoritative = AuthoritativeFields::capture(&derived, &[], derived.clone());
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
let result = authoritative.enforce(Mapping::new());
assert!(!result.contains_key(Value::from("dns")));
assert_eq!(result["dns"], derived["dns"]);
}
}
@@ -1292,8 +1429,8 @@ mod authoritative_field_tests {
#[cfg(test)]
mod tests {
use super::{
ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
process_profile_items, use_keys,
AuthoritativeFields, ChainItem, ChainType, Mapping, cleanup_proxy_groups, ensure_lan_bind_address,
process_global_items, process_profile_items, use_keys,
};
use std::collections::HashMap;
@@ -1301,6 +1438,89 @@ mod tests {
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
}
#[tokio::test]
async fn dns_settings_override_only_filled_fields_after_merge_and_script() {
let profile = mapping(
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
hosts: {profile.example: 192.0.2.1}",
);
let settings = mapping(
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8},\n\
proxy-server-nameserver: [], fallback: null, listen: ' '}\n\
hosts: {settings.example: 192.0.2.2}",
);
let merge = mapping(
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
hosts: {merge.example: 192.0.2.3}",
);
let script = r#"function main(config) {
config.dns = {
ipv6: false,
nameserver: ["4.4.4.4"],
"nameserver-policy": {"script.example": "4.4.4.4"},
"proxy-server-nameserver": ["4.4.4.4"],
fallback: ["4.4.4.4"],
listen: "127.0.0.1:1053"
};
config.hosts = {"script.example": "192.0.2.4"};
return config;
}"#;
for enabled in [false, true] {
let (config, dns_settings) = if enabled {
super::merge_dns_config(profile.clone(), settings.clone())
} else {
(profile.clone(), Mapping::new())
};
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let merge_item = |uid: &str| ChainItem {
uid: uid.into(),
data: ChainType::Merge(merge.clone()),
};
let profile_name = "test-profile".into();
let (config, keys, logs) = process_global_items(
config,
vec![],
HashMap::new(),
merge_item("global-merge"),
ChainItem::to_script("global-script", script),
&profile_name,
&authoritative,
)
.await;
let (config, _, logs) = process_profile_items(
config,
keys,
logs,
merge_item("profile-merge"),
ChainItem::to_script("profile-script", script),
&profile_name,
&authoritative,
)
.await;
let manual = config.clone();
let result = authoritative.enforce(config);
let source = if enabled { &settings } else { &manual };
assert_eq!(result["dns"]["nameserver-policy"], source["dns"]["nameserver-policy"]);
assert_eq!(result["hosts"], source["hosts"]);
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
for key in ["proxy-server-nameserver", "fallback", "listen"] {
assert_eq!(result["dns"][key], manual["dns"][key]);
}
if enabled {
for uid in ["global-merge", "global-script", "profile-merge", "profile-script"] {
assert!(
logs[uid]
.iter()
.any(|(_, message)| message.contains("dns.nameserver-policy"))
);
}
}
}
}
#[tokio::test]
async fn manual_overrides_follow_expected_priority() {
let mut config = mapping(
@@ -1345,6 +1565,7 @@ mod tests {
);
let profile_name = "test-profile".into();
let authoritative = AuthoritativeFields::capture(&config, &[], Mapping::new());
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
@@ -1352,6 +1573,7 @@ mod tests {
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, _) = process_profile_items(
@@ -1361,6 +1583,7 @@ mod tests {
profile_merge,
profile_script,
&profile_name,
&authoritative,
)
.await;
@@ -1498,19 +1721,21 @@ mod tests {
}
#[test]
fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() {
fn empty_dns_settings_leave_inherited_fields_overridable() {
let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#);
let dns_ipv6 = super::snapshot_dns_ipv6(&app_config);
let (config, dns_settings) =
super::merge_dns_config(app_config, mapping("dns: {ipv6: false, proxy-server-nameserver: []}"));
let authoritative = AuthoritativeFields::capture(&config, &[], dns_settings);
let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#);
let result = super::enforce_dns_ipv6(hijacked, dns_ipv6);
let result = authoritative.enforce(hijacked);
assert_eq!(
result
.get("dns")
.and_then(|value| value.get("ipv6"))
.and_then(serde_yaml_ng::Value::as_bool),
Some(false)
Some(true)
);
assert_eq!(
result
+25 -7
View File
@@ -1,10 +1,14 @@
use anyhow::Result;
use anyhow::{Result, ensure};
use clash_verge_draft::DraftTransaction;
use serde::Serialize;
use smartstring::alias::String;
use crate::{
config::{Config, dns::dns_override_source, profiles::PROFILE_WRITE_LOCK},
config::{
Config,
dns::{ProfileDnsSettings, dns_override_source},
profiles::PROFILE_WRITE_LOCK,
},
constants::files::DNS_CONFIG,
core::{CoreManager, handle::Handle},
utils::dirs,
@@ -17,10 +21,18 @@ pub enum DnsOverrideOutcome {
ConfirmationRequired { source: String },
}
pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Result<DnsOverrideOutcome> {
pub async fn set_dns_override(
profile_uid: String,
enabled: bool,
confirmation: Option<String>,
) -> Result<DnsOverrideOutcome> {
let _profile_write = PROFILE_WRITE_LOCK.lock().await;
let _config_write = Config::lock_config_write().await;
let profiles = Config::profiles().await.data_arc();
ensure!(
profiles.current.as_ref() == Some(&profile_uid),
"The active profile changed; retry the DNS setting"
);
let source = dns_override_source(
profiles.current.as_deref().unwrap_or_default(),
&profiles.current_mapping().await?,
@@ -37,8 +49,13 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
let verge = Config::verge().await;
let transaction = DraftTransaction::begin(vec![&verge])?;
verge.edit_draft(|draft| {
draft.enable_dns_settings = Some(enabled);
draft.dns_override_confirmation = if enabled { source } else { None };
draft.profile_dns_settings.insert(
profile_uid.clone(),
ProfileDnsSettings {
enabled,
confirmation: if enabled { source } else { None },
},
);
});
CoreManager::global().update_config_checked().await?;
@@ -47,8 +64,9 @@ pub async fn set_dns_override(enabled: bool, confirmation: Option<String>) -> Re
// A subscription update can replace the file while Mihomo is validating it.
if enabled && let Some(state) = state.filter(|state| !state.enabled) {
verge.edit_draft(|draft| {
draft.enable_dns_settings = Some(false);
draft.dns_override_confirmation = None;
draft
.profile_dns_settings
.insert(profile_uid, ProfileDnsSettings::default());
});
if let Some(source) = state.source {
outcome = DnsOverrideOutcome::ConfirmationRequired { source };
+13 -1
View File
@@ -44,7 +44,14 @@ mod app_init {
let mut builder = builder
.plugin(tauri_plugin_clash_verge_sysinfo::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(
tauri_plugin_updater::Builder::new()
.default_version_comparator(|current, release| {
release.version > current
|| core::updater::is_build_to_stable(&current.to_string(), &release.version.to_string())
})
.build(),
)
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
@@ -163,6 +170,11 @@ mod app_init {
cmd::apply_dns_config,
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
cmd::get_clash_logs,
+6 -2
View File
@@ -16,9 +16,13 @@ pub async fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T> {
bail!("file not found \"{}\"", path.display());
}
let yaml_str = tokio::fs::read_to_string(path).await?;
let yaml_str = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read YAML file {}", path.display()))?;
Ok(with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) }).await?)
with_encryption(|| async { serde_yaml_ng::from_str::<T>(&yaml_str) })
.await
.with_context(|| format!("failed to parse YAML file {}", path.display()))
}
pub async fn read_mapping(path: &Path) -> Result<Mapping> {
+35 -29
View File
@@ -9,7 +9,7 @@ use crate::{
help,
},
};
use anyhow::Result;
use anyhow::{Context as _, Result};
use chrono::{Local, TimeZone as _};
use clash_verge_logging::Type;
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -28,8 +28,15 @@ async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
];
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
let mut entries = fs::read_dir(temp_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let mut entries = fs::read_dir(temp_dir)
.await
.with_context(|| format!("failed to read log snapshot directory {}", temp_dir.display()))?;
while let Some(entry) = entries.next_entry().await.with_context(|| {
format!(
"failed to read next entry in log snapshot directory {}",
temp_dir.display()
)
})? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("log") {
let _ = path.remove_if_exists().await;
@@ -109,15 +116,28 @@ pub async fn delete_log() -> Result<()> {
};
if log_dir.exists() {
let mut log_read_dir = fs::read_dir(&log_dir).await?;
while let Some(entry) = log_read_dir.next_entry().await? {
let mut log_read_dir = fs::read_dir(&log_dir)
.await
.with_context(|| format!("failed to read log directory {}", log_dir.display()))?;
while let Some(entry) = log_read_dir
.next_entry()
.await
.with_context(|| format!("failed to read next entry in log directory {}", log_dir.display()))?
{
std::mem::drop(process_file(entry).await);
}
}
if service_log_dir.exists() {
let mut service_log_read_dir = fs::read_dir(service_log_dir).await?;
while let Some(entry) = service_log_read_dir.next_entry().await? {
let mut service_log_read_dir = fs::read_dir(&service_log_dir)
.await
.with_context(|| format!("failed to read service log directory {}", service_log_dir.display()))?;
while let Some(entry) = service_log_read_dir.next_entry().await.with_context(|| {
format!(
"failed to read next entry in service log directory {}",
service_log_dir.display()
)
})? {
std::mem::drop(process_file(entry).await);
}
}
@@ -228,27 +248,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
Ok(())
}
/// Raise existing sub-floor update intervals, once per install.
///
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
/// them, so the marker is written even when nothing needed raising.
/// Once per install; the marker is written even when no interval needed raising.
pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? {
return Ok(());
}
// Same order as every other writer holding it (lock, then draft permit): save_file runs
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
let min = constants::profile::MIN_UPDATE_INTERVAL;
let raised = Config::profiles()
.await
.with_data_modify(|mut profiles: IProfiles| async move {
// `IProfiles::new` normalises items to Some when it read the file, and returns a
// bare default() when it failed. A failed load also raises zero — without this the
// marker would burn the one shot on profiles nobody managed to load.
// A failed profiles load (bare default) must not burn the one-shot marker.
if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
}
@@ -370,14 +384,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
"nameserver-policy".into(),
Value::Mapping(serde_yaml_ng::Mapping::new()),
),
(
"proxy-server-nameserver".into(),
Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("tls://223.5.5.5".into()),
]),
),
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
(
@@ -509,7 +516,7 @@ pub async fn init_resources() -> Result<()> {
std::mem::drop(fs::create_dir_all(&res_dir).await);
}
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
let file_list = ["Country.mmdb", "ASN.mmdb", "geoip.dat", "geosite.dat"];
for file in file_list.iter() {
let src_path = res_dir.join(file);
@@ -637,10 +644,9 @@ async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) {
logging!(
error,
Type::Setup,
"failed to copy resources '{}' to '{:?}', {}",
file,
dest,
err
"failed to copy resource {} to {}: {err}",
src.display(),
dest.display()
);
}
};
-8
View File
@@ -18,8 +18,6 @@ pub enum NotificationEvent<'a> {
LightweightModeEntered,
ProfilesReactivated,
AppQuit,
#[cfg(target_os = "macos")]
AppHidden,
}
/// Whether no app window can currently show the failure.
@@ -95,11 +93,5 @@ pub async fn notify_event<'a>(event: NotificationEvent<'a>) {
let body = clash_verge_i18n::t!("notifications.appQuit.body");
notify(title, body);
}
#[cfg(target_os = "macos")]
NotificationEvent::AppHidden => {
let title = clash_verge_i18n::t!("notifications.appHidden.title");
let body = clash_verge_i18n::t!("notifications.appHidden.body");
notify(title, body);
}
}
}
+6 -5
View File
@@ -1,3 +1,4 @@
use anyhow::Context as _;
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "macos")]
use std::path::Path;
@@ -9,7 +10,7 @@ const DNS_STATE_FILE: &str = ".original_dns.txt";
fn dns_state_dir() -> anyhow::Result<PathBuf> {
// The DNS scripts persist .original_dns.txt relative to their working directory.
let dir = crate::utils::dirs::app_home_dir()?;
std::fs::create_dir_all(&dir)?;
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
Ok(dir)
}
@@ -37,14 +38,14 @@ pub async fn set_public_dns(dns_server: String) {
};
let script = resource_dir.join("set_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "set_dns.sh not found");
logging!(error, Type::Config, "DNS script not found: {}", script.display());
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
@@ -85,14 +86,14 @@ pub async fn restore_public_dns() {
};
let script = resource_dir.join("unset_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "unset_dns.sh not found");
logging!(error, Type::Config, "DNS script not found: {}", script.display());
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {}", e);
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
+3
View File
@@ -9,6 +9,7 @@ use crate::{
handle::Handle,
hotkey::Hotkey,
logger,
manager::CoreFailure,
service::{SERVICE_MANAGER, ServiceManager},
tray::Tray,
},
@@ -221,6 +222,8 @@ async fn init_core_manager() -> bool {
Ok(initialized) => initialized,
Err(error) => {
logging!(error, Type::Setup, "core manager initialization failed: {error:#}");
CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}")));
Handle::notice_message("core_start::error", "");
false
}
}
+10 -4
View File
@@ -1,5 +1,5 @@
use crate::utils::dirs::{self, PathBufExec as _};
use anyhow::{Result, anyhow};
use anyhow::{Context as _, Result, anyhow};
use clash_verge_logging::{Type, logging};
use std::fs;
use std::os::windows::process::CommandExt as _;
@@ -100,8 +100,14 @@ async fn cleanup_legacy_shortcuts() -> Result<()> {
let old_shortcut = startup_dir.join("Clash-Verge.lnk");
let new_shortcut = startup_dir.join("Clash Verge.lnk");
old_shortcut.remove_if_exists().await?;
new_shortcut.remove_if_exists().await?;
old_shortcut
.remove_if_exists()
.await
.with_context(|| format!("failed to remove startup shortcut {}", old_shortcut.display()))?;
new_shortcut
.remove_if_exists()
.await
.with_context(|| format!("failed to remove startup shortcut {}", new_shortcut.display()))?;
Ok(())
}
@@ -327,7 +333,7 @@ pub async fn set_auto_launch(is_enable: bool, is_admin: bool) -> Result<()> {
let other = if is_admin { TaskMode::User } else { TaskMode::Admin };
if let Err(err) = cleanup_legacy_shortcuts().await {
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {}", err);
logging!(warn, Type::Setup, "Failed to cleanup legacy startup shortcuts: {err:#}");
}
if is_enable {
+1 -1
View File
@@ -14,7 +14,7 @@ pub(crate) fn report_error(error: &anyhow::Error) {
eprintln!("[clash-verge] startup failed: {detail}");
let log_result = startup_log_path().and_then(|path| {
append_error(&path, &detail)?;
append_error(&path, &detail).with_context(|| format!("startup log {}", path.display()))?;
Ok(path)
});
let message = match log_result {
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "2.5.4",
"version": "2.5.7",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"active": true,
+2 -1
View File
@@ -15,11 +15,12 @@
},
"rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"],
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
"desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"],
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
}
},
+16 -3
View File
@@ -13,12 +13,24 @@ interface SortableItemProps {
index: number
group?: string
disabled?: boolean
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
measureElement?: (element: HTMLDivElement | null) => void
dataIndex?: number
style?: CSSProperties
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
}
export const SortableItem = (props: SortableItemProps) => {
const { id, index, group, disabled, children, style } = props
const {
id,
index,
group,
disabled,
measureElement,
dataIndex,
children,
style,
} = props
const { ref, handleRef, sortable, isDragging } = useSortable({
id,
index,
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
(element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element)
measureElement?.(element)
},
[handleRef, ref],
[handleRef, ref, measureElement],
)
const mergedStyle: CSSProperties = {
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
}
return (
<div ref={setRef} style={mergedStyle}>
<div ref={setRef} data-index={dataIndex} style={mergedStyle}>
{children}
</div>
)
+320 -214
View File
@@ -1,6 +1,7 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import {
AccessTimeRounded,
ArrowDropDown,
ChevronRight,
NetworkCheckRounded,
WifiOff as SignalError,
@@ -15,25 +16,22 @@ import {
import {
Box,
Button,
ButtonBase,
Chip,
ClickAwayListener,
FormControl,
IconButton,
InputLabel,
type MenuProps,
MenuItem,
MenuList,
Paper,
Popper,
Select,
type SelectChangeEvent,
type PopperProps,
Tooltip,
Typography,
alpha,
useTheme,
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
@@ -68,6 +66,40 @@ const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type'
const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500
const PROXY_MENU_VIEWPORT_MARGIN = 8
const proxyMenuModifiers: PopperProps['modifiers'] = [
{
name: 'menuSize',
enabled: true,
phase: 'beforeRead',
fn: ({ state }) => {
const popper = state.elements.popper
const anchor = state.elements.reference.getBoundingClientRect()
const viewportHeight = popper.ownerDocument.documentElement.clientHeight
// Use the larger side before flip runs so sizing cannot alternate with placement.
const availableHeight =
Math.max(anchor.top, viewportHeight - anchor.bottom) -
PROXY_MENU_VIEWPORT_MARGIN
popper.style.width = `${anchor.width}px`
popper.style.setProperty(
'--proxy-menu-max-height',
`${Math.max(0, Math.min(PROXY_MENU_MAX_HEIGHT, availableHeight))}px`,
)
state.rects.popper.width = popper.offsetWidth
state.rects.popper.height = popper.offsetHeight
},
},
{
name: 'showMenu',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
state.styles.popper.visibility = 'visible'
},
},
]
interface ProxyOption {
memberIndex: number
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
member.kind === 'node' ? member.node.recordId : member.ref.name
}`
const hasPointerCoordinates = (
value: unknown,
): value is { clientX: number; clientY: number } =>
typeof value === 'object' &&
value !== null &&
'clientX' in value &&
typeof value.clientX === 'number' &&
'clientY' in value &&
typeof value.clientY === 'number'
const eventHitsElement = (event: unknown, element: HTMLElement | null) => {
if (!element) return false
const nativeEvent =
typeof event === 'object' && event !== null && 'nativeEvent' in event
? event.nativeEvent
: event
if (!hasPointerCoordinates(nativeEvent)) return false
const rect = element.getBoundingClientRect()
return (
nativeEvent.clientX >= rect.left &&
nativeEvent.clientX <= rect.right &&
nativeEvent.clientY >= rect.top &&
nativeEvent.clientY <= rect.bottom
)
}
const sortProxyOptions = (
options: ProxyOption[],
sortType: ProxySortType,
@@ -216,58 +220,71 @@ const sortProxyOptions = (
.map(({ option }) => option)
}
interface PersistentProxySelectProps {
interface PersistentSelectProps {
label: string
groupName: string
fixed?: string
value: string
selectedName: string
selectedDelay: number
options: ProxyOption[]
displayValue: React.ReactNode
open: boolean
disabled: boolean
keepOpenRef: React.RefObject<HTMLElement | null>
onOpen: () => void
onClose: () => void
onChange: (value: string) => void
onOpenChange: (open: boolean) => void
renderOptions: () => React.ReactNode
}
const PersistentProxySelect = ({
const PersistentSelect = ({
label,
groupName,
fixed,
value,
selectedName,
selectedDelay,
options,
displayValue,
open,
disabled,
keepOpenRef,
onOpen,
onClose,
onChange,
}: PersistentProxySelectProps) => {
const anchorRef = useRef<HTMLDivElement>(null)
const listboxId = 'current-proxy-node-listbox'
const labelId = 'proxy-select-label'
const fixedProxyInUsed = selectedName === fixed
onOpenChange,
renderOptions,
}: PersistentSelectProps) => {
const id = useId()
const labelId = `${id}-label`
const valueId = `${id}-value`
const listboxId = `${id}-listbox`
const anchorRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const popperOptions = useMemo<PopperProps['popperOptions']>(
() => ({
onFirstUpdate: () => {
const list = listRef.current
const paper = list?.parentElement
const item =
list?.querySelector<HTMLElement>(
'[aria-selected="true"]:not([aria-disabled="true"])',
) ??
list?.querySelector<HTMLElement>(
'[role="option"]:not([aria-disabled="true"])',
)
if (!item || !paper) return
// Focusing before positioning can scroll the page instead of just the menu.
item.focus({ preventScroll: true })
paper.scrollTop +=
item.getBoundingClientRect().top -
paper.getBoundingClientRect().top -
(paper.clientHeight - item.offsetHeight) / 2
},
}),
[],
)
useEffect(() => {
if (!open) return
const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onClose()
onOpenChange(false)
} else if (event.key === 'Escape') {
onClose()
anchorRef.current
?.querySelector<HTMLElement>('[role="combobox"]')
?.focus()
onOpenChange(false)
anchorRef.current?.focus({ preventScroll: true })
}
}
document.addEventListener('keydown', closeFromKeyboard)
return () => document.removeEventListener('keydown', closeFromKeyboard)
}, [onClose, open])
}, [onOpenChange, open])
return (
<ClickAwayListener
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
if (target instanceof Node && keepOpenRef.current?.contains(target)) {
return
}
onClose()
onOpenChange(false)
}}
>
<Box>
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small">
<InputLabel id={labelId}>{label}</InputLabel>
<Select
labelId={labelId}
value={value}
open={false}
onOpen={onOpen}
label={label}
<Box
sx={{
position: 'relative',
borderRadius: 1,
'& fieldset': {
borderColor: (theme) =>
alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
},
...(!disabled && {
'&:hover fieldset': { borderColor: 'text.primary' },
'&:focus-within fieldset': {
borderColor: 'primary.main',
borderWidth: 2,
},
'&:focus-within legend': { color: 'primary.main' },
}),
}}
>
<ButtonBase
ref={anchorRef}
type="button"
role="combobox"
aria-labelledby={`${labelId} ${valueId}`}
aria-haspopup="listbox"
aria-controls={open ? listboxId : undefined}
aria-expanded={open}
disabled={disabled}
renderValue={() => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
)}
SelectDisplayProps={{
'aria-controls': open ? listboxId : undefined,
'aria-expanded': open ? 'true' : 'false',
onClick={() => onOpenChange(!open)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
onOpenChange(true)
}
}}
sx={{
width: '100%',
height: 40,
px: 1.75,
borderRadius: 'inherit',
justifyContent: 'space-between',
textAlign: 'left',
color: disabled ? 'text.disabled' : 'text.primary',
}}
>
<MenuItem value={value}>{selectedName}</MenuItem>
</Select>
</FormControl>
<Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
{displayValue}
</Box>
<ArrowDropDown
sx={{
ml: 1,
color: disabled ? 'action.disabled' : 'action.active',
transform: open ? 'rotate(180deg)' : undefined,
}}
/>
</ButtonBase>
<Box
component="fieldset"
sx={{
position: 'absolute',
inset: '-5px 0 0',
m: 0,
px: 1,
minWidth: 0,
border: '1px solid',
borderRadius: 'inherit',
pointerEvents: 'none',
}}
>
<Box
component="legend"
id={labelId}
sx={{
px: 0.5,
typography: 'caption',
lineHeight: '11px',
color: disabled ? 'text.disabled' : 'text.secondary',
}}
>
{label}
</Box>
</Box>
</Box>
<Popper
open={open}
anchorEl={anchorRef.current}
placement="bottom-start"
modifiers={proxyMenuModifiers}
popperOptions={popperOptions}
sx={{
width: anchorRef.current?.clientWidth,
visibility: 'hidden',
zIndex: (theme) => theme.zIndex.modal,
}}
>
<Paper
elevation={8}
sx={{ maxHeight: PROXY_MENU_MAX_HEIGHT, overflow: 'auto' }}
sx={{
maxHeight: `var(--proxy-menu-max-height, ${PROXY_MENU_MAX_HEIGHT}px)`,
overflow: 'auto',
}}
>
<MenuList
ref={listRef}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
autoFocusItem
variant="selectedMenu"
>
{options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})}
{open && renderOptions()}
</MenuList>
</Paper>
</Popper>
@@ -413,6 +415,124 @@ const PersistentProxySelect = ({
)
}
interface PersistentProxySelectProps
extends Omit<PersistentSelectProps, 'displayValue' | 'renderOptions'> {
value: string
selectedName: string
groupName: string
fixed?: string
selectedDelay: number
options: ProxyOption[]
onChange: (value: string) => void
}
const PersistentProxySelect = ({
groupName,
fixed,
selectedName,
selectedDelay,
options,
value,
onChange,
...props
}: PersistentProxySelectProps) => {
const fixedProxyInUsed = selectedName === fixed
return (
<PersistentSelect
{...props}
displayValue={
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography noWrap>{selectedName}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(selectedDelay)}
color={convertDelayColor(selectedDelay)}
/>
{fixedProxyInUsed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
}
renderOptions={() =>
options.map((option) => {
const selected = option.value === value
const isFixed = option.name === fixed
const delay = option.disabled
? -1
: delayManager.getDelayFix(option.member, groupName)
return (
<MenuItem
key={option.value}
role="option"
aria-selected={selected}
selected={selected}
disabled={option.disabled}
onClick={() => {
if (!option.disabled) onChange(option.value)
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
pr: 1,
}}
>
{isFixed && (
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '5px',
...(!fixedProxyInUsed && {
filter: 'grayscale(1)',
}),
}}
>
📌
</span>
)}
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{option.name}
</Typography>
{!option.disabled && (
<Chip
size="small"
label={delayManager.formatDelay(delay)}
color={convertDelayColor(delay)}
sx={{
minWidth: '60px',
height: '22px',
flexShrink: 0,
}}
/>
)}
</MenuItem>
)
})
}
/>
)
}
export const CurrentProxyCard = () => {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
const [selectedGroupName, setSelectedGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
useEffect(() => {
setOpenSelect(null)
}, [mode])
const delayButtonRef = useRef<HTMLButtonElement>(null)
const delays = useGroupDelays(selectedGroupName || null)
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => {
(newGroupName: string) => {
if (isGlobalMode || isDirectMode) return
const newGroupName = event.target.value
setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
},
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
refreshProxy()
})
const handleGroupMenuClose = useCallback<NonNullable<MenuProps['onClose']>>(
(event, reason) => {
if (
reason === 'backdropClick' &&
eventHitsElement(event, delayButtonRef.current)
) {
void handleCheckDelay()
return
}
setOpenSelect(null)
},
[handleCheckDelay],
)
const proxyOptions = useMemo(
() =>
isDirectMode
isDirectMode || openSelect !== 'proxy'
? []
: sortProxyOptions(
unsortedProxyOptions,
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
[
delays,
isDirectMode,
openSelect,
unsortedProxyOptions,
sortType,
defaultLatencyTimeout,
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
/>
)}
</Box>
<FormControl
fullWidth
variant="outlined"
size="small"
sx={{ mb: 1.5 }}
>
<InputLabel id="proxy-group-select-label">
{t('home.components.currentProxy.labels.group')}
</InputLabel>
<Select
labelId="proxy-group-select-label"
value={selectedGroupName}
onChange={handleGroupChange}
open={openSelect === 'group'}
onOpen={() => setOpenSelect('group')}
<Box sx={{ mb: 1.5 }}>
<PersistentSelect
label={t('home.components.currentProxy.labels.group')}
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
open={openSelect === 'group'}
disabled={isGlobalMode || isDirectMode}
MenuProps={{ onClose: handleGroupMenuClose }}
>
{selectableGroups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
keepOpenRef={delayButtonRef}
onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
renderOptions={() =>
selectableGroups.map((group) => (
<MenuItem
key={group.name}
role="option"
aria-selected={group.name === selectedGroupName}
selected={group.name === selectedGroupName}
onClick={() => handleGroupChange(group.name)}
>
<Typography noWrap>{group.name}</Typography>
</MenuItem>
))
}
/>
</Box>
<PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')}
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
}
selectedName={selectedProxyName}
selectedDelay={currentDelay}
options={isDirectMode ? [] : proxyOptions}
options={proxyOptions}
open={openSelect === 'proxy'}
disabled={isDirectMode}
keepOpenRef={delayButtonRef}
onOpen={() => setOpenSelect('proxy')}
onClose={() => setOpenSelect(null)}
onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
onChange={handleProxyChange}
/>
</Box>
+2 -1
View File
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
@@ -41,7 +42,7 @@ const round = keyframes`
// 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => {
if (!url) return '-'
if (url.startsWith('http')) return new URL(url).host
if (isValidUrl(url)) return new URL(url).host
return 'local'
}
+14 -1
View File
@@ -4,6 +4,7 @@ import {
AdminPanelSettingsOutlined,
DnsOutlined,
ExtensionOutlined,
PauseCircleOutlined,
} from '@mui/icons-material'
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
import { useLockFn } from 'ahooks'
@@ -29,7 +30,8 @@ export const SystemInfoCard = () => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const navigate = useNavigate()
const { isAdminMode, isSidecarMode, mutateSystemState } = useSystemState()
const { runningMode, isAdminMode, isSidecarMode, mutateSystemState } =
useSystemState()
const { installServiceAndRestartCore } = useServiceInstaller()
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
@@ -135,6 +137,14 @@ export const SystemInfoCard = () => {
)
const getModeIcon = () => {
if (runningMode === 'NotRunning') {
return (
<PauseCircleOutlined
sx={{ color: 'text.secondary', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.notRunning')}
/>
)
}
if (isAdminMode) {
if (!isSidecarMode) {
return (
@@ -174,6 +184,9 @@ export const SystemInfoCard = () => {
}
const getModeText = () => {
if (runningMode === 'NotRunning') {
return t('home.components.systemInfo.badges.notRunning')
}
if (isAdminMode) {
if (!isSidecarMode) {
return t('home.components.systemInfo.badges.adminServiceMode')
+11 -6
View File
@@ -100,13 +100,18 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
const item = navItemMap.get(path)
if (!item) return null
// A registered sortable, even a disabled one, gets the drag-and-drop ARIA
// attributes, so screen readers announce every item as a disabled draggable.
if (!menuUnlocked) {
return (
<LayoutItem key={item.path} to={item.path} icon={item.icon}>
{t(item.label)}
</LayoutItem>
)
}
return (
<SortableItem
key={item.path}
id={item.path}
index={index}
disabled={!menuUnlocked}
>
<SortableItem key={item.path} id={item.path} index={index}>
{(sortable) => (
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
{t(item.label)}
+64
View File
@@ -3,6 +3,7 @@ import {
Snackbar,
Alert,
IconButton,
Link,
Box,
Stack,
type SnackbarOrigin,
@@ -18,6 +19,14 @@ import {
showNotice,
} from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { openExternalUrl } from '@/utils/open-external-url'
const SERVICE_PERMISSION_NOTICE =
'settings.feedback.notifications.clashService.permissionFallback'
const SERVICE_PERMISSION_GUIDE =
'https://clash-verge-rev.github.io/faq/windows.html#service-core-permissions'
const SERVICE_OWNER_NOTICE =
'settings.feedback.notifications.clashService.appDataNotOwned'
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
@@ -59,6 +68,55 @@ const resolveNoticeMessage = (
const i18n = notice.i18n
if (!i18n) return bound(notice.message)
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
return (
<>
{t(SERVICE_PERMISSION_NOTICE, {
reason: t(
'settings.feedback.notifications.clashService.permissionRejectedReason',
),
})}
<Box sx={{ mt: 1 }}>
<Link
href={SERVICE_PERMISSION_GUIDE}
color="inherit"
underline="always"
onClick={(event) => {
event.preventDefault()
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
showNotice.error,
)
}}
>
{t(
'settings.feedback.notifications.clashService.permissionRepairGuide',
)}
</Link>
</Box>
</>
)
}
if (i18n.key === SERVICE_OWNER_NOTICE) {
return (
<>
{t(SERVICE_OWNER_NOTICE)}
<Box
component="code"
sx={{
display: 'block',
mt: 1,
userSelect: 'text',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{String(i18n.params?.command ?? '')}
</Box>
</>
)
}
const source = (i18n.params ?? {}) as Record<string, unknown>
// Bound both parameters and their final interpolation.
const params =
@@ -132,6 +190,12 @@ const resolveNoticeCopyText = (
notice: NoticeItem,
t: TranslationFn,
): string | undefined => {
if (notice.i18n?.key === SERVICE_PERMISSION_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.reason)
}
if (notice.i18n?.key === SERVICE_OWNER_NOTICE) {
return extractNoticeCopyText(notice.i18n.params?.command)
}
if (
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
notice.i18n?.key === 'shared.feedback.notices.raw'
@@ -0,0 +1,106 @@
import { useState } from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import {
getRuntimeState,
installService,
restartCore,
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
vi.mock('react', () => ({ useState: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
installService: vi.fn(),
restartCore: vi.fn(),
}))
vi.mock('@/services/notice-service', () => ({
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({
useQuery: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useState)
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([true, vi.fn()])
})
it.each([
{ service: 'ready', mode: 'NotRunning' },
{ service: 'unknown', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'Service' },
{ service: 'unavailable', mode: 'NotRunning', opInFlight: true },
{ service: 'unavailable', mode: 'NotRunning', pendingAction: 'reinstall' },
] satisfies Partial<RunState>[])(
'does not offer Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
const dialog = ServiceMigrationDialog()
expect(dialog.props.open).toBe(true)
expect(dialog.props.disableCancel).toBe(true)
},
)
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
vi.mocked(useQuery).mockReturnValue({
data: { service: 'notInstalled', mode: 'NotRunning' },
} as ReturnType<typeof useQuery>)
const reason = 'core path C:\\ is writable by Everyone'
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
vi.mocked(getRuntimeState).mockResolvedValue({
service: 'unavailable',
mode: 'Sidecar',
sidecarAllowed: true,
serviceNeedsAttention: false,
} as RunState)
const dialog = ServiceMigrationDialog()
dialog.props.onOk()
await vi.waitFor(() => {
expect(showNotice.warning).toHaveBeenCalledWith(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason },
0,
)
})
expect(restartCore).not.toHaveBeenCalled()
expect(showNotice.error).not.toHaveBeenCalled()
expect(showNotice.success).not.toHaveBeenCalled()
expect(vi.mocked(useState).mock.results[2].value[1]).toHaveBeenLastCalledWith(
false,
)
})
it.each([
{ service: 'notInstalled', mode: 'NotRunning' },
{ service: 'versionMismatch', mode: 'NotRunning' },
{ service: 'unavailable', mode: 'NotRunning' },
{ service: 'notInstalled', mode: 'Sidecar', pendingAction: 'install' },
] satisfies Partial<RunState>[])(
'offers Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
},
)
@@ -13,6 +13,7 @@ import {
repairService,
restartCore,
type RunState,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -44,7 +45,24 @@ export const ServiceMigrationDialog = () => {
? 'install'
: 'reinstall'
const open = loading || workflowIncomplete || needsDecision
const showCheckingMessage = loading || !needsDecision
const checking =
loading ||
!runState ||
runState.opInFlight ||
runState.service === 'unknown'
const showCheckingMessage = checking || !needsDecision
const canContinue = Boolean(
runState &&
!stateRefreshFailed &&
(runState.pendingAction === 'install'
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
: !runState.pendingAction &&
!runState.sidecarAllowed &&
runState.mode === 'NotRunning' &&
(runState.service === 'notInstalled' ||
runState.service === 'versionMismatch' ||
runState.service === 'unavailable')),
)
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
@@ -62,16 +80,15 @@ export const ServiceMigrationDialog = () => {
const handleServiceAction = async () => {
setLoading(true)
setWorkflowIncomplete(true)
let actionSucceeded = false
let outcome: ServiceInstallOutcome | undefined
try {
if (remedy === 'install') {
await installService()
outcome = await installService()
} else if (remedy === 'repair') {
await repairService()
outcome = await repairService()
} else {
await reinstallService()
outcome = await reinstallService()
}
actionSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.actionFailed',
@@ -89,10 +106,20 @@ export const ServiceMigrationDialog = () => {
error,
)
}
if (!actionSucceeded || !initialRefreshSucceeded) {
if (!outcome || !initialRefreshSucceeded) {
setLoading(false)
return
}
if (outcome.status === 'sidecar') {
setWorkflowIncomplete(false)
setLoading(false)
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
return
}
let restartSucceeded = false
try {
@@ -165,8 +192,8 @@ export const ServiceMigrationDialog = () => {
: 'layout.components.serviceMigration.reinstall',
)}
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
disableOk={loading}
disableCancel={loading}
disableOk={checking}
disableCancel={checking || !canContinue}
loading={loading}
onOk={() => void handleServiceAction()}
onCancel={() => void handleContinue()}
@@ -8,9 +8,11 @@ import {
getRuntimeState,
installService,
patchVergeConfig,
reinstallService,
restartCore,
type FailedOperation,
type PendingFailure,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import {
@@ -20,23 +22,31 @@ import {
type ServiceRequest,
type ServiceRequestReason,
} from '@/services/service-request'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'restartOnly'
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
const remedyFor = (reason: ServiceRequestReason): Remedy =>
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
reason === 'serviceLocationRefused'
? 'reinstallAndRestart'
: reason === 'sysproxySidecarReady'
? 'restartOnly'
: 'installAndRestart'
const EXPLANATION = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
sysproxySidecarReady:
'layout.components.sysproxyPrivilege.serviceReadyMessage',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
serviceLocationRefused:
'layout.components.serviceMigration.locationRefusedMessage',
} as const
const TITLE = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
serviceLocationRefused: 'layout.components.serviceMigration.repair',
} as const
const stateToRestore = (
@@ -69,7 +79,6 @@ const STEP_MESSAGE = {
applying: 'layout.components.sysproxyPrivilege.applying',
} as const
/** Guide recovery from a refused system-proxy write. */
export const SysproxyPrivilegeDialog = () => {
const { t } = useTranslation()
const { failure, dismiss } = useDialogFailure()
@@ -91,26 +100,48 @@ export const SysproxyPrivilegeDialog = () => {
const handleFix = async () => {
try {
if (remedy === 'installAndRestart') {
let outcome: ServiceInstallOutcome | undefined
if (remedy === 'reinstallAndRestart') {
setStep('installing')
await installService()
outcome = await reinstallService()
} else if (remedy === 'installAndRestart') {
setStep('installing')
outcome = await installService()
}
if (outcome?.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
close()
return
}
setStep('restarting')
await restartCore()
// Verify that restart actually moved the core into the service.
const runState = await getRuntimeState()
if (runState.mode === 'Service') {
// Retry the original request now that the service can apply it.
const usingAdminFallback =
remedy !== 'reinstallAndRestart' &&
getSystem() === 'windows' &&
runState.mode === 'Sidecar' &&
runState.isAdmin &&
runState.sidecarAllowed &&
runState.service === 'unavailable'
if (runState.mode === 'Service' || usingAdminFallback) {
if (restoring !== undefined) {
setStep('applying')
await patchVergeConfig(restoring)
}
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
if (!usingAdminFallback) {
showNotice.success(
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.success'
: restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
}
close()
} else {
showNotice.error(
@@ -129,9 +160,11 @@ export const SysproxyPrivilegeDialog = () => {
open={Boolean(request)}
title={t(TITLE[reason])}
okBtn={t(
remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.reinstall'
: remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
)}
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
// Keep the primary spinner visible; only cancellation is unavailable.
+1 -3
View File
@@ -1,6 +1,5 @@
import { Button } from '@mui/material'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
@@ -13,7 +12,6 @@ interface Props {
export const UpdateButton = (props: Props) => {
const { className } = props
const { t } = useTranslation()
const viewerRef = useRef<DialogRef>(null)
const { updateInfo } = useUpdate()
@@ -31,7 +29,7 @@ export const UpdateButton = (props: Props) => {
className={className}
onClick={() => viewerRef.current?.open()}
>
{t('shared.actions.new')}
New
</Button>
</>
)
+3
View File
@@ -33,6 +33,7 @@ type EditorLanguage = 'yaml' | 'javascript' | 'css'
export interface EditorViewerProps {
open: boolean
title?: string | ReactNode
description?: ReactNode
value: string
language: EditorLanguage
path: string
@@ -50,6 +51,7 @@ export interface EditorViewerProps {
export const EditorViewer = ({
open,
title,
description,
value,
language,
path,
@@ -207,6 +209,7 @@ export const EditorViewer = ({
overflow: 'hidden',
}}
>
{description}
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
<BaseLoadingOverlay isLoading={loading} />
{!loading && (
+53
View File
@@ -0,0 +1,53 @@
import { Alert, Box, Typography } from '@mui/material'
import { Trans, useTranslation } from 'react-i18next'
const stageHints = {
globalMerge: 'profiles.modals.editor.enhance.globalMerge',
globalScript: 'profiles.modals.editor.enhance.globalScript',
profileMerge: 'profiles.modals.editor.enhance.profileMerge',
profileScript: 'profiles.modals.editor.enhance.profileScript',
} as const
interface Props {
stage: keyof typeof stageHints
}
export const EnhanceHint = ({ stage }: Props) => {
const { t } = useTranslation()
const orderComponents = Object.fromEntries(
Object.keys(stageHints).map((key) => [
key,
<Box
key={key}
component={key === stage ? 'strong' : 'span'}
sx={
key === stage
? {
fontWeight: 'bold',
color: 'primary.contrastText',
bgcolor: 'primary.main',
borderRadius: 0.5,
px: 0.5,
boxDecorationBreak: 'clone',
}
: undefined
}
/>,
]),
)
return (
<Alert severity="info" sx={{ mb: 1.5, flexShrink: 0 }}>
<Typography variant="body2">{t(stageHints[stage])}</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
<Trans
defaults={t('profiles.modals.editor.enhance.order')}
components={orderComponents}
/>
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
{t('profiles.modals.editor.enhance.settingsPriority')}
</Typography>
</Alert>
)
}
@@ -0,0 +1,184 @@
import { move } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragEndEvent,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { useVirtualizer } from '@tanstack/react-virtual'
import { type CSSProperties, type ReactNode, useRef, useState } from 'react'
import { SortableItem } from '../base'
type GroupedVirtualCategory = 'prepend' | 'original' | 'append'
export interface GroupedVirtualItem<T> {
id: string
category: GroupedVirtualCategory
item: T
}
// eslint-disable-next-line react-refresh/only-export-components
export function buildGroupedItems<T>(
prepend: T[],
original: T[],
append: T[],
getKey: (item: T) => string,
): GroupedVirtualItem<T>[] {
const build = (
category: GroupedVirtualCategory,
list: T[],
): GroupedVirtualItem<T>[] =>
list.map((item) => ({
id: `${category}:${getKey(item)}`,
category,
item,
}))
return [
...build('prepend', prepend),
...build('original', original),
...build('append', append),
]
}
interface GroupedVirtualListProps<T> {
items: GroupedVirtualItem<T>[]
estimateSize?: number
renderItem: (entry: GroupedVirtualItem<T>) => ReactNode
onReorder: (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => void
style?: CSSProperties
}
/**
* 用于编辑规则/节点/代理组功能的 prepend/original/append 分组虚拟化列表
*/
export function GroupedVirtualList<T>(props: GroupedVirtualListProps<T>) {
const {
items: itemsProp,
estimateSize = 56,
renderItem,
onReorder,
style,
} = props
const [items, setItems] = useState(itemsProp)
const [prevItemsProp, setPrevItemsProp] = useState(itemsProp)
const snapshotRef = useRef(itemsProp)
const scrollRef = useRef<HTMLDivElement>(null)
if (itemsProp !== prevItemsProp) {
setPrevItemsProp(itemsProp)
setItems(itemsProp)
}
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
overscan: 15,
gap: 8,
getItemKey: (index) => items[index]?.id ?? index,
})
const onDragStart = () => {
snapshotRef.current = items
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (!source || !target) return
const sourceEntry = items.find((entry) => entry.id === source.id)
const targetEntry = items.find((entry) => entry.id === target.id)
if (
!sourceEntry ||
!targetEntry ||
sourceEntry.category !== targetEntry.category
) {
event.preventDefault()
return
}
setItems((current) => move(current, event))
}
const onDragEnd = (event: DragEndEvent) => {
const source = event.operation.source
if (event.canceled) {
setItems(snapshotRef.current)
return
}
if (!isSortable(source)) return
const snapshot = snapshotRef.current
const sourceEntry = snapshot.find((entry) => entry.id === source.id)
if (!sourceEntry) return
const { category } = sourceEntry
if (category !== 'prepend' && category !== 'append') return
const activeIndex = snapshot
.filter((entry) => entry.category === category)
.findIndex((entry) => entry.id === source.id)
const overIndex = items
.filter((entry) => entry.category === category)
.findIndex((entry) => entry.id === source.id)
if (activeIndex < 0 || overIndex < 0 || activeIndex === overIndex) return
onReorder(category, activeIndex, overIndex)
}
return (
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<div ref={scrollRef} style={{ ...style, overflow: 'auto' }}>
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
marginRight: '4px',
}}
>
{virtualizer.getVirtualItems().map((vi) => {
const entry = items[vi.index]
if (!entry) return null
return (
<SortableItem
key={vi.key}
id={entry.id}
index={vi.index}
disabled={entry.category === 'original'}
measureElement={virtualizer.measureElement}
dataIndex={vi.index}
style={{
position: 'absolute',
top: `${vi.start}px`,
left: 0,
width: '100%',
}}
>
{renderItem(entry)}
</SortableItem>
)
})}
</div>
</div>
</DragDropProvider>
)
}
+71 -170
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -43,13 +35,7 @@ import {
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { GroupItem } from '@/components/profile/group-item'
import {
getNetworkInterfaces,
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
proxiesUid: string
mergeUid: string
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredGroupList,
filteredAppendSeq,
(group) => group.name,
),
[filteredPrependSeq, filteredGroupList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyGroupConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredGroupList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<GroupItem
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? 'delete'
: 'original'
<GroupItem
type={isDeleted ? 'delete' : 'original'}
group={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
group={filteredGroupList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredGroupList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredGroupList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredGroupList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<GroupItem
type="prepend"
group={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
// prevent drag over if source and target are in different groups
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredGroupList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+7 -3
View File
@@ -43,9 +43,11 @@ import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic'
import { EnhanceHint } from './enhance-hint'
import { ProfileBox } from './profile-box'
import { ProxiesEditorViewer } from './proxies-editor-viewer'
import { QrViewer } from './qr-viewer'
@@ -909,6 +911,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{mergeOpen && (
<EditorViewer
open={true}
title={t('profiles.components.menu.extendConfig')}
description={<EnhanceHint stage="profileMerge" />}
value={mergeDocument.value}
language="yaml"
path={`merge:${option?.merge ?? ''}.yaml`}
@@ -922,6 +926,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
{scriptOpen && (
<EditorViewer
open={true}
title={t('profiles.components.menu.extendScript')}
description={<EnhanceHint stage="profileScript" />}
value={scriptDocument.value}
language="javascript"
path={`script:${option?.script ?? ''}.js`}
@@ -965,9 +971,7 @@ export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) {
if (!url) return ''
const regex = /https?:\/\/(.+?)\//
const result = url.match(regex)
return result ? result[1] : 'local file'
return isValidUrl(url) ? new URL(url).host : 'local file'
}
function parseExpire(expire?: number) {
+6
View File
@@ -17,6 +17,7 @@ import { useEditorDocument } from '@/hooks/use-editor-document'
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { EnhanceHint } from './enhance-hint'
import { LogViewer } from './log-viewer'
import { ProfileBox } from './profile-box'
@@ -213,6 +214,11 @@ export const ProfileMore = (props: Props) => {
<EditorViewer
open={true}
title={t(globalTitles[id])}
description={
<EnhanceHint
stage={id === 'Merge' ? 'globalMerge' : 'globalScript'}
/>
}
value={document.value}
language={id === 'Merge' ? 'yaml' : 'javascript'}
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
+71 -166
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers'
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -35,12 +27,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -50,6 +37,12 @@ import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import parseUri from '@/utils/uri-parser'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
profileUid: string
property: string
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredProxyList,
filteredAppendSeq,
(proxy) => proxy.name,
),
[filteredPrependSeq, filteredProxyList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<IProxyConfig>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item.name)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`prepend:${item.name}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter((v) => v.name !== item.name),
)
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? 'delete'
: 'original'
<ProxyItem
type={isDeleted ? 'delete' : 'original'}
proxy={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
} else {
setDeleteSeq((prev) => [...prev, item.name])
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredProxyList[newIndex].name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item.name}
id={`append:${item.name}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
</SortableItem>
)
})}
</>
<ProxyItem
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
return (
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
return
}
setPrependSeq(arrayMove(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(arrayMove(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = []
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
+90 -177
View File
@@ -1,11 +1,4 @@
import {
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { arrayMove } from '@dnd-kit/helpers'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
@@ -36,13 +29,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import {
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network'
import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props {
groupsUid: string
mergeUid: string
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match],
)
const renderItem = (index: number): React.ReactNode => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
const items = useMemo(
() =>
buildGroupedItems(
filteredPrependSeq,
filteredRuleList,
filteredAppendSeq,
(rule) => rule,
),
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
)
const renderItem = (entry: GroupedVirtualItem<string>) => {
const { category, item } = entry
if (category === 'original') {
const isDeleted = deleteSeq.includes(item)
return (
<>
{filteredPrependSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`prepend:${item}`}
index={itemIndex}
group="prepend"
style={{ margin: '8px 0' }}
>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
)
})}
</>
)
} else if (index < filteredRuleList.length + shift) {
const newIndex = index - shift
return (
<Box sx={{ margin: '8px 0' }}>
<RuleItem
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? 'delete'
: 'original'
<RuleItem
type={isDeleted ? 'delete' : 'original'}
ruleRaw={item}
onDelete={() => {
if (isDeleted) {
setDeleteSeq(deleteSeq.filter((v) => v !== item))
} else {
setDeleteSeq((prev) => [...prev, item])
}
ruleRaw={filteredRuleList[newIndex]}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
setDeleteSeq(
deleteSeq.filter((v) => v !== filteredRuleList[newIndex]),
)
} else {
setDeleteSeq((prev) => [...prev, filteredRuleList[newIndex]])
}
}}
/>
</Box>
}}
/>
)
} else {
}
if (category === 'prepend') {
return (
<>
{filteredAppendSeq.map((item, itemIndex) => {
return (
<SortableItem
key={item}
id={`append:${item}`}
index={itemIndex}
group="append"
style={{ margin: '8px 0' }}
>
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
</SortableItem>
<RuleItem
type="prepend"
ruleRaw={item}
onDelete={() => {
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
onAppend={() => {
setAppendSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
})}
</>
setPrependSeq(prependSeq.filter((v) => v !== item))
}}
/>
)
}
return (
<RuleItem
type="append"
ruleRaw={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
onPrepend={() => {
setPrependSeq((prev) =>
prev.includes(item) ? prev : [...prev, item],
)
setAppendSeq(appendSeq.filter((v) => v !== item))
}}
/>
)
}
const reorder = (list: string[], startIndex: number, endIndex: number) => {
const result = Array.from(list)
const [removed] = result.splice(startIndex, 1)
result.splice(endIndex, 0, removed)
return result
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
const onReorder = (
category: 'prepend' | 'append',
activeIndex: number,
overIndex: number,
) => {
const list = category === 'prepend' ? prependSeq : appendSeq
const filtered =
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
return
}
setPrependSeq(reorder(prependSeq, activeRealIndex, overRealIndex))
setList(arrayMove(list, activeRealIndex, overRealIndex))
}
const onAppendDragEnd = async (event: DragEndEvent) => {
const { operation, canceled } = event
const { source, target } = operation
if (canceled || !target || !isSortable(source)) return
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
appendSeq,
filteredAppendSeq,
activeIndex,
)
const overRealIndex = findRealIndex(appendSeq, filteredAppendSeq, overIndex)
if (
activeRealIndex < 0 ||
overRealIndex < 0 ||
activeRealIndex === overRealIndex
) {
return
}
setAppendSeq(reorder(appendSeq, activeRealIndex, overRealIndex))
}
const onDragOver = (event: DragOverEvent) => {
const { operation } = event
if (!isSortableOperation(operation)) return
const { source, target } = operation
if (source?.group !== target?.group) {
event.preventDefault()
}
}
const onDragEnd = async (event: DragEndEvent) => {
const source = event.operation.source
if (!isSortable(source)) return
const { initialGroup, group } = source.sortable
if (initialGroup !== group) return
if (group === 'prepend') {
await onPrependDragEnd(event)
} else if (group === 'append') {
await onAppendDragEnd(event)
}
}
const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property)
@@ -765,6 +681,13 @@ export const RulesEditorViewer = (props: Props) => {
autoComplete="new-password"
size="small"
sx={{ minWidth: '240px' }}
slotProps={{
htmlInput: {
autoCorrect: 'off',
autoCapitalize: 'off',
spellCheck: false,
},
}}
value={ruleContent}
required={ruleType.required ?? true}
error={(ruleType.required ?? true) && !ruleContent}
@@ -854,22 +777,12 @@ export const RulesEditorViewer = (props: Props) => {
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider
sensors={[PointerSensor, KeyboardSensor]}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
>
<VirtualList
count={
filteredRuleList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
estimateSize={56}
renderItem={renderItem}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</DragDropProvider>
<GroupedVirtualList
items={items}
renderItem={renderItem}
onReorder={onReorder}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
/>
</List>
</>
) : (
@@ -17,6 +17,8 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base'
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+6
View File
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
@@ -95,6 +97,10 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay()
}}
>
+17 -21
View File
@@ -1,5 +1,6 @@
import { RestartAltRounded } from '@mui/icons-material'
import {
Alert,
Box,
Button,
FormControl,
@@ -33,6 +34,7 @@ import {
Switch,
} from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
@@ -171,11 +173,7 @@ const DEFAULT_DNS_CONFIG = {
],
fallback: [],
'nameserver-policy': {},
'proxy-server-nameserver': [
'https://doh.pub/dns-query',
'https://dns.alidns.com/dns-query',
'tls://223.5.5.5',
],
'proxy-server-nameserver': [],
'direct-nameserver': [],
'direct-nameserver-follow-policy': false,
'fallback-filter': {
@@ -190,6 +188,12 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation()
const { mutateClash } = useClash()
const { verge } = useVerge()
const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
verge?.enable_dns_settings ??
false)
: false
const themeMode = useThemeMode()
const [open, setOpen] = useState(false)
@@ -340,8 +344,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
listen: values.listen,
'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange,
'fake-ip-range6':
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
'fake-ip-range6': values.fakeIpRange6,
'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3,
'respect-rules': values.respectRules,
@@ -351,6 +354,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
'fake-ip-filter': parseList(values.fakeIpFilter),
'default-nameserver': parseList(values.defaultNameserver),
nameserver: parseList(values.nameserver),
'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy),
'direct-nameserver-follow-policy': values.directNameserverFollowPolicy,
'fallback-filter': {
geoip: values.fallbackGeoip,
@@ -364,11 +368,6 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
'direct-nameserver': parseList(values.directNameserver),
}
const policy = parseNameserverPolicy(values.nameserverPolicy)
if (Object.keys(policy).length > 0) {
dnsConfig['nameserver-policy'] = policy
}
return dnsConfig
}, [values])
@@ -380,10 +379,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config.dns = dnsConfig
}
const hosts = parseHosts(values.hosts)
if (Object.keys(hosts).length > 0) {
config.hosts = hosts
}
config.hosts = parseHosts(values.hosts)
setYamlContent(yaml.dump(config, { forceQuotes: true }))
}, [generateDnsConfig, values.hosts])
@@ -512,10 +508,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config.dns = dnsConfig
}
const hosts = parseHosts(values.hosts)
if (Object.keys(hosts).length > 0) {
config.hosts = hosts
}
config.hosts = parseHosts(values.hosts)
} else {
const parsedConfig = yaml.load(yamlContent)
if (typeof parsedConfig !== 'object' || parsedConfig === null) {
@@ -565,7 +558,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
return
}
if (verge?.enable_dns_settings) {
if (dnsEnabled) {
await invoke('apply_dns_config', { apply: true })
mutateClash()
}
@@ -653,6 +646,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)}
onOk={onSave}
>
<Alert severity="info" sx={{ mb: 2 }}>
{t('settings.modals.dns.dialog.profileScope')}
</Alert>
<Typography
variant="body2"
color="warning.main"
+37 -11
View File
@@ -9,6 +9,7 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { useClashLog } from '@/hooks/use-clash-log'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { invoke_uwp_tool, setDnsOverride } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
@@ -36,6 +37,12 @@ const SettingClash = ({ onError }: Props) => {
const { clash, version, mutateClash, patchClash } = useClash()
const { verge, mutateVerge } = useVerge()
const { current: currentProfile } = useProfiles()
const dnsEnabled = currentProfile
? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ??
verge?.enable_dns_settings ??
false)
: false
const displayedMixedPort = useDisplayedMixedPort()
const [, setClashLog] = useClashLog()
@@ -46,7 +53,10 @@ const SettingClash = ({ onError }: Props) => {
'unified-delay': unifiedDelay,
} = clash ?? {}
const [dnsConfirmation, setDnsConfirmation] = useState<string | null>(null)
const [dnsConfirmation, setDnsConfirmation] = useState<{
profileUid: string
source: string
} | null>(null)
const [dnsUpdating, setDnsUpdating] = useState(false)
const webRef = useRef<DialogRef>(null)
@@ -72,12 +82,17 @@ const SettingClash = ({ onError }: Props) => {
}
const handleDnsToggle = useLockFn(
async (enable: boolean, confirmation?: string) => {
async (
enable: boolean,
confirmation?: string,
profileUid = currentProfile?.uid,
) => {
if (!profileUid) return
setDnsUpdating(true)
try {
const outcome = await setDnsOverride(enable, confirmation)
const outcome = await setDnsOverride(profileUid, enable, confirmation)
if (outcome.status === 'confirmation_required') {
setDnsConfirmation(outcome.source)
setDnsConfirmation({ profileUid, source: outcome.source })
} else {
setDnsConfirmation(null)
}
@@ -114,7 +129,12 @@ const SettingClash = ({ onError }: Props) => {
onCancel={closeDnsConfirmation}
onClose={closeDnsConfirmation}
onOk={() => {
if (dnsConfirmation) void handleDnsToggle(true, dnsConfirmation)
if (dnsConfirmation)
void handleDnsToggle(
true,
dnsConfirmation.source,
dnsConfirmation.profileUid,
)
}}
>
<Typography variant="body2">
@@ -151,16 +171,22 @@ const SettingClash = ({ onError }: Props) => {
<SettingItem
label={t('settings.sections.clash.form.fields.dnsOverwrite')}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
<>
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
<TooltipIcon
title={t('settings.modals.dns.dialog.profileScope')}
sx={{ opacity: '0.7' }}
/>
</>
}
>
<Switch
edge="end"
checked={verge?.enable_dns_settings ?? false}
disabled={dnsUpdating || dnsConfirmation !== null}
checked={dnsEnabled}
disabled={!currentProfile || dnsUpdating || dnsConfirmation !== null}
onChange={(_, checked) => handleDnsToggle(checked)}
/>
</SettingItem>
+15 -4
View File
@@ -3,17 +3,18 @@ import { useCallback } from 'react'
import { installService, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
const executeWithErrorHandling = async (
operation: () => Promise<void>,
const executeWithErrorHandling = async <T>(
operation: () => Promise<T>,
loadingKey: string,
successKey?: string,
) => {
try {
showNotice.info(loadingKey)
await operation()
const result = await operation()
if (successKey) {
showNotice.success(successKey)
}
return result
} catch (err) {
showNotice.error(err)
throw err
@@ -22,9 +23,19 @@ const executeWithErrorHandling = async (
export const useServiceInstaller = () => {
const installServiceAndRestartCore = useCallback(async () => {
await executeWithErrorHandling(
const outcome = await executeWithErrorHandling(
() => installService(),
'settings.statuses.clashService.installing',
)
if (outcome.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
return
}
showNotice.success(
'settings.feedback.notifications.clashService.installSuccess',
)
+1
View File
@@ -63,6 +63,7 @@
"settings": "الإعدادات"
},
"badges": {
"notRunning": "النواة لا تعمل",
"adminMode": "Administrator Mode",
"serviceMode": "وضع الخدمة",
"sidecarMode": "User Mode",
+2 -1
View File
@@ -21,8 +21,9 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "لا يمكن للخدمة تشغيل النواة لأن مسار التثبيت أو أذونات المجلد لم تجتز فحص الأمان. أعد تثبيت الخدمة لاستعادة ملفاتها وأذوناتها، ثم حاول مجددًا. قد يطلب Windows موافقة المسؤول.",
"title": "Service update required",
"message": "An older Clash Verge service installation was detected. Reinstall it to use the current secure IPC protocol, or continue with Sidecar for this session.",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.",
"reinstall": "Reinstall service",

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