Compare commits

...
Author SHA1 Message Date
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
Tunglies e385d37e5b ci(gate): align linked-issue-check with the slop review trust tier
linked-issue-check only exempted drafts and the no-issue-needed
label, so member and collaborator PRs failed the linkage gate while
the slop review itself skipped them via skip-roles (#7944).

Skip OWNER, MEMBER, COLLABORATOR, and bot authors as well, reading
the association from the event payload so the permission-free gate
design holds. External contributors still need an issue reference.
2026-09-16 11:07:45 +08:00
Tunglies 2f097790ed fix(updater): downgrade manual check failure notice to warning
Manual update-check failures surfaced as red error toasts that read as
an app fault. Add a warning severity to the notice service and use it
in both check entry points alongside a console.warn; automatic checks
and install failures keep their existing behavior.
2026-09-16 09:14:12 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 619d6ae36a chore(deps): update npm dependencies (#7896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 17:37:05 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
de24496fa7 chore(deps): update cargo dependencies (#7765)
* chore(deps): update cargo dependencies

* chore(deps): Revert `rfd` to version 0.16.0

Assisted-by: vscode-copilot-chat:deepseek-flash
Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-09-15 17:36:45 +00:00
wonfen f58e0a0af5 fix(service): stage approved cores during Windows service installation 2026-09-15 16:09:54 +08:00
Tunglies a8e8fe66dc fix(tray): patch tray-icon for macOS 27 click reentrancy
Close: #7256
2026-09-15 14:03:09 +08:00
Alechron 75c921b466 fix(traffic): avoid scientific notation in traffic display near 1000 threshold (#7928)
* fix(traffic): avoid scientific notation in traffic display near 1000 threshold

When a traffic value in any unit (KB, MB, etc.) falls in the 999.5–1000
range, toPrecision(3) rounds it to 1000 but outputs scientific notation
"1.00e+3" instead of plain "1000". Check Math.round(dat) >= 1000 instead
of dat >= 1000 to catch this rounding boundary.

Signed-off-by: Alec <alechron@outlook.com>

* fix(traffic): avoid scientific notation in formatConnectionTraffic

Signed-off-by: Alec <alechron@outlook.com>

---------

Signed-off-by: Alec <alechron@outlook.com>
2026-09-15 05:17:35 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> e3bdea4386 chore(deps): update github/gh-aw-actions action to v0.89.15 (#7941)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 23:42:26 +00:00
Tunglies ce963d175a feat(tun): support mips stack from mihomo v1.19.31
Add `mips` to the TUN stack switch so the stack shipped in mihomo
v1.19.31 is selectable from the GUI. Older cores reject the value,
which the changelog entry discloses.
2026-09-15 00:50:20 +08:00
wonfen d289a608d9 refactor(sysproxy): delegate macOS network monitoring to sysproxy-rs 2026-09-14 22:10:33 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8a5548af10 chore(deps): update github/gh-aw-actions action to v0.89.13 (#7939)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 10:41:24 +00:00
wonfen 1d9bca13c8 chore(deps): bump clash-verge-service-ipc to v2.7.0
The service installer now admits the staged core copies through Windows
Firewall, so TUN system/mixed stacks pass traffic in service mode again
without users re-allowing the new path by hand.

Refs #7915
2026-09-14 18:33:53 +08:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 6f19c3dfaa chore(deps): update github/gh-aw-actions action to v0.89.12 (#7934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-14 04:22:51 +00:00
90 changed files with 2690 additions and 2417 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.21.0", "version": "24.21.0",
"pnpmVersion": "12.3.3" "pnpmVersion": "12.5.1"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.98.1", "version": "1.98.1",
+22 -7
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0", "version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
}, },
"github/gh-aw-actions/setup@v0.89.1": { "github/gh-aw-actions/setup@v0.89.15": {
"repo": "github/gh-aw-actions/setup", "repo": "github/gh-aw-actions/setup",
"version": "v0.89.1", "version": "v0.89.15",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae" "sha": "045beb2d14bda8d0c1f2e83b41527f63770d2855"
} }
}, },
"containers": { "containers": {
@@ -22,6 +22,11 @@
"digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98", "digest": "sha256:f7df036c86575527b61f3f7df91c4412349a12b2a74988d929eafa2999230c98",
"pinned_image": "ghcr.io/github/gh-aw-firewall/agent:0.28.14@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/api-proxy:0.28.13": { "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.13": {
"image": "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", "digest": "sha256:61f12dcff38eb008b645d87a5541763452f409d9040eb243718764944167b1a2",
@@ -32,6 +37,11 @@
"digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5", "digest": "sha256:6f95e2234dd9bd6333a8ff28ccea7ecf0204acd4a09108723844dbd2bf6268c5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/api-proxy:0.28.14@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/squid:0.28.13": { "ghcr.io/github/gh-aw-firewall/squid:0.28.13": {
"image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13", "image": "ghcr.io/github/gh-aw-firewall/squid:0.28.13",
"digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56", "digest": "sha256:58a8dc5be3fbafeef7da0532c38bd9047f86bf1b3c4cfd7d9b7d8d28e80a2b56",
@@ -42,10 +52,15 @@
"digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5", "digest": "sha256:2ce8df3abf3e9b76e9c0cf5863da41f1ab3f89b20ad14b988806ab89e7bf2cd5",
"pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.14@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": { "ghcr.io/github/gh-aw-firewall/squid:0.28.16": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.20", "image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16",
"digest": "sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684", "digest": "sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.20@sha256:980ea7aa4fb07e444f0e2c6e3af5aff8b45e4e415d617667b8ba064a1768b684" "pinned_image": "ghcr.io/github/gh-aw-firewall/squid:0.28.16@sha256:452197f2e241b2cda8eb0b5674960aa8ca544a64dc242e24f8263c3df6452919"
},
"ghcr.io/github/gh-aw-mcpg:v0.4.21": {
"image": "ghcr.io/github/gh-aw-mcpg:v0.4.21",
"digest": "sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e",
"pinned_image": "ghcr.io/github/gh-aw-mcpg:v0.4.21@sha256:f26f665840660581510746ed84f5fc706f4ca8c48833757dfb4d72d86587201e"
}, },
"ghcr.io/github/gh-aw-node": { "ghcr.io/github/gh-aw-node": {
"image": "ghcr.io/github/gh-aw-node", "image": "ghcr.io/github/gh-aw-node",
+8
View File
@@ -30,11 +30,19 @@ jobs:
PR_BODY: ${{ github.event.pull_request.body }} PR_BODY: ${{ github.event.pull_request.body }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
DRAFT: ${{ github.event.pull_request.draft }} DRAFT: ${{ github.event.pull_request.draft }}
ASSOCIATION: ${{ github.event.pull_request.author_association }}
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
run: | run: |
if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then if [ "$DRAFT" = "true" ] || printf '%s' "$LABELS" | grep -q 'no-issue-needed'; then
echo "Draft or no-issue-needed; linkage check skipped." echo "Draft or no-issue-needed; linkage check skipped."
exit 0 exit 0
fi fi
# OWNER/MEMBER/COLLABORATOR and bots mirror the slop review's
# skip-roles/skip-bots trust tier.
if [ "$AUTHOR_TYPE" = "Bot" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
echo "Bot or trusted author ($ASSOCIATION); linkage check skipped."
exit 0
fi
if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -Eq '#[0-9]+|issues/[0-9]+'; then
echo "Issue reference found." echo "Issue reference found."
else else
+2 -2
View File
@@ -3,8 +3,8 @@ name: Auto Build
on: on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00 # UTC+8 06:00, 18:00 -> UTC 22:00, 10:00
- cron: '0 4,10 * * *' - cron: '0 10,22 * * *'
permissions: write-all permissions: write-all
env: env:
TAG_NAME: autobuild TAG_NAME: autobuild
+2 -2
View File
@@ -31,6 +31,6 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install gh-aw extension - name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@96dc6d905bdbe195f719ddc990a3f5b59b751016 # v0.89.10 uses: github/gh-aw-actions/setup-cli@045beb2d14bda8d0c1f2e83b41527f63770d2855 # v0.89.15
with: with:
version: v0.89.1 version: v0.89.15
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 24.21.0 nodejs 24.21.0
pnpm 12.4.1 pnpm 12.5.1
rust 1.98.1 rust 1.98.1
Generated
+456 -598
View File
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -46,6 +46,12 @@ inherits = "fast-release"
codegen-units = 1 codegen-units = 1
split-debuginfo = "unpacked" split-debuginfo = "unpacked"
[patch.crates-io]
# macOS Golden Gate fix for tray-icon click reentrancy issue
# Remove the following patch once the fix is merged upstream(tauri)
# https://github.com/tauri-apps/tray-icon/pull/365
tray-icon = { git = "https://github.com/Tunglies/tray-icon.git", branch = "fix/macos-27-click-reentrancy" }
[workspace.dependencies] [workspace.dependencies]
clash-verge-draft = { path = "crates/clash-verge-draft" } clash-verge-draft = { path = "crates/clash-verge-draft" }
clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" } clash-verge-media-unlock = { path = "crates/clash-verge-media-unlock" }
@@ -55,10 +61,10 @@ clash-verge-i18n = { path = "crates/clash-verge-i18n" }
clash-verge-limiter = { path = "crates/clash-verge-limiter" } clash-verge-limiter = { path = "crates/clash-verge-limiter" }
tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" } tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", branch = "main" }
tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" } tauri-plugin-clash-verge-sysinfo = { path = "crates/tauri-plugin-clash-verge-sysinfo" }
tracing = "0.1" tracing = "0.1.44"
tauri = { version = "2.11.5" } tauri = { version = "2.11.5" }
tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-clipboard-manager = "2.3.3"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
@@ -70,9 +76,15 @@ tokio = { version = "1.53.1", features = [
"sync", "sync",
] } ] }
flexi_logger = "0.31.10" flexi_logger = "0.31.10"
log = "0.4.33" log = "0.4.34"
regex = "1.13.1" regex = "1.13.1"
reqwest = { version = "0.13.4", features = ["json", "cookies", "gzip", "rustls", "form"] } reqwest = { version = "0.13.5", features = [
"json",
"cookies",
"gzip",
"rustls",
"form",
] }
rust_iso3166 = "0.2.0" rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" } smartstring = { version = "1.0.1" }
@@ -80,7 +92,7 @@ smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" } serde_json = { version = "1.0.151" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.1" } bitflags = { version = "2.13.2" }
socket2 = "0.6.5" socket2 = "0.6.5"
# *** For Windows platform only *** # *** For Windows platform only ***
@@ -93,7 +105,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_Security_Authorization", "Win32_Security_Authorization",
"Win32_Storage_FileSystem", "Win32_Storage_FileSystem",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_System_JobObjects" "Win32_System_JobObjects",
] } ] }
# ********************************* # *********************************
+9 -3
View File
@@ -1,11 +1,9 @@
## v2.5.4 ## v2.5.4
> [!IMPORTANT]
> 服务模式下,配置目录中的规则集、代理集合文件只是缓存副本,手动修改不会影响正在运行的内核。需要手动维护的文件请使用 `type: file`。
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复首页代理组和节点下拉菜单:再次点击无法收起或误选、小窗口显示不全、切换模式后意外展开
- 修复系统代理切换失败后,开关状态显示错误的问题 - 修复系统代理切换失败后,开关状态显示错误的问题
- 修复代理守卫失败后反复重试且没有提示的问题 - 修复代理守卫失败后反复重试且没有提示的问题
- 修复未开启系统代理时,启动仍提示代理错误的问题 - 修复未开启系统代理时,启动仍提示代理错误的问题
@@ -27,8 +25,12 @@
**🖥️ Windows** **🖥️ Windows**
- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题
- 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题
- 修复 Windows 系统代理设置偶发失败的问题 - 修复 Windows 系统代理设置偶发失败的问题
- 修复 Windows 关机被应用阻止的问题 - 修复 Windows 关机被应用阻止的问题
- 修复 Windows 服务模式下 TUN 使用 system/mixed 堆栈时无法接管流量的问题,防火墙放行规则改为自动添加
- 修复 Windows 配置中 GEO 规则较多时,首次启动内核失败的问题
**🍎 macOS** **🍎 macOS**
@@ -39,6 +41,7 @@
**🐧 Linux** **🐧 Linux**
- 修复 Linux 安装或卸载服务时授权失败的问题 - 修复 Linux 安装或卸载服务时授权失败的问题
- 修复 Fedora 等启用 SELinux 的系统更新后服务无法启动的问题
**🍎/🐧 macOS/Linux** **🍎/🐧 macOS/Linux**
@@ -60,6 +63,7 @@
- 新增代理页排查提示,方便检查订阅和内核状态 - 新增代理页排查提示,方便检查订阅和内核状态
- 新增订阅更新频率提醒,间隔不足 24 小时时提示 - 新增订阅更新频率提醒,间隔不足 24 小时时提示
- 新增启动失败提示,显示原因并保存启动日志 - 新增启动失败提示,显示原因并保存启动日志
- 新增 TUN「Mips」堆栈选项,需配合 mihomo v1.19.31 及以上内核
**🍎 macOS** **🍎 macOS**
@@ -75,7 +79,9 @@
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化首页节点菜单关闭时的刷新性能
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整 - 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致 - 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒 - 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
- 优化代理列表刷新、排序和页面切换速度 - 优化代理列表刷新、排序和页面切换速度
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
rust-i18n = "4.2.1" rust-i18n = "4.2.2"
sys-locale = "0.3.2" sys-locale = "0.3.2"
[lints] [lints]
+8 -8
View File
@@ -8,8 +8,8 @@ notifications:
body: '{mode} moduna geçildi.' body: '{mode} moduna geçildi.'
systemProxyToggled: systemProxyToggled:
title: Sistem Vekil'i title: Sistem Vekil'i
'on': System proxy has been enabled. 'on': Sistem vekili etkinleştirildi.
'off': System proxy has been disabled. 'off': Sistem vekili devre dışı bırakıldı.
systemProxyFailed: systemProxyFailed:
title: Sistem Vekili title: Sistem Vekili
body: Sistem vekil sunucusu ayarlanamadı. Nedenini görmek için Clash Verge'i açın. 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. body: Art arda birkaç başarısızlıktan sonra durdu. Nedenini görmek için Clash Verge'i açın.
tunModeToggled: tunModeToggled:
title: TUN Modu title: TUN Modu
'on': TUN mode has been enabled. 'on': TUN modu etkinleştirildi.
'off': TUN mode has been disabled. 'off': TUN modu devre dışı bırakıldı.
lightweightModeEntered: lightweightModeEntered:
title: Hafif Mod title: Hafif Mod
body: Hafif moda geçildi. body: Hafif moda geçildi.
@@ -33,10 +33,10 @@ notifications:
title: Uygulama Gizlendi title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor. body: Clash Verge arka planda çalışıyor.
updateReady: updateReady:
title: Clash Verge Update title: Clash Verge Güncellemesi
body: A new version (v{version}) has been downloaded and is ready to install. body: Yeni bir sürüm (v{version}) indirildi ve kurulmaya hazır.
installNow: Install Now installNow: Şimdi Kur
later: Later later: Daha Sonra
service: service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir. 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. adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.
+15 -15
View File
@@ -56,13 +56,13 @@
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-http": "~2.5.9", "@tauri-apps/plugin-http": "~2.6.0",
"@tauri-apps/plugin-opener": "2.5.4", "@tauri-apps/plugin-opener": "2.5.5",
"@tauri-apps/plugin-updater": "2.10.1", "@tauri-apps/plugin-updater": "2.11.0",
"ahooks": "^3.9.7", "ahooks": "^3.9.7",
"cidr-block": "^2.3.0", "cidr-block": "^2.3.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.1",
"dayjs": "1.11.21", "dayjs": "1.11.23",
"foxact": "^0.3.4", "foxact": "^0.3.4",
"foxts": "^5.4.2", "foxts": "^5.4.2",
"i18next": "^26.2.0", "i18next": "^26.2.0",
@@ -73,11 +73,11 @@
"monaco-yaml": "^5.5.0", "monaco-yaml": "^5.5.0",
"nanoid": "^6.0.0", "nanoid": "^6.0.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.8", "react": "19.3.0",
"react-dom": "19.2.8", "react-dom": "19.3.0",
"react-error-boundary": "6.1.2", "react-error-boundary": "6.1.5",
"react-hook-form": "^7.76.1", "react-hook-form": "^7.76.1",
"react-i18next": "17.0.11", "react-i18next": "17.0.14",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^8.0.0", "react-router": "^8.0.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
@@ -96,12 +96,12 @@
"@tauri-apps/cli": "2.11.4", "@tauri-apps/cli": "2.11.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"@types/react": "19.2.18", "@types/react": "19.3.0",
"@types/react-dom": "19.2.4", "@types/react-dom": "19.3.0",
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.0.2", "@vitejs/plugin-legacy": "^8.0.2",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"@wdio/tauri-service": "1.3.0", "@wdio/tauri-service": "1.4.0",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"axios": "^1.16.1", "axios": "^1.16.1",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
@@ -121,7 +121,7 @@
"https-proxy-agent": "^9.0.0", "https-proxy-agent": "^9.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"knip": "6.32.1", "knip": "6.35.1",
"lint-staged": "^17.0.5", "lint-staged": "^17.0.5",
"sass": "^1.100.0", "sass": "^1.100.0",
"tar": "^7.5.15", "tar": "^7.5.15",
@@ -131,8 +131,8 @@
"vite": "^8.0.14", "vite": "^8.0.14",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.0", "vitest": "^5.0.0",
"webdriverio": "9.30.0", "webdriverio": "9.31.9",
"ws": "8.21.0" "ws": "8.21.3"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,mjs}": [ "*.{ts,tsx,js,mjs}": [
@@ -144,5 +144,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.4.1" "packageManager": "pnpm@12.5.1"
} }
+430 -525
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"], "matchFileNames": [".github/workflows/*.lock.yml"],
"enabled": false "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", "description": "Skip TypeScript 7.0 series",
"matchManagers": ["npm"], "matchManagers": ["npm"],
@@ -78,7 +84,7 @@
}, },
{ {
"description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer", "description": "Group PNPM version updates across package.json, tool-versions, workflows, and devcontainer",
"matchPackageNames": ["pnpm", "ghcr.io/devcontainers/features/pnpm"], "matchPackageNames": ["pnpm"],
"groupName": "pnpm" "groupName": "pnpm"
}, },
{ {
@@ -131,6 +137,16 @@
], ],
"depNameTemplate": "rust-lang/rust", "depNameTemplate": "rust-lang/rust",
"datasourceTemplate": "github-releases" "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"], "postUpdateOptions": ["pnpmDedupe"],
+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: * Usage:
* pnpm release-version <version> * pnpm release-version <version>
@@ -108,17 +108,20 @@ async function updatePackageVersion(newVersion) {
'[INFO]: Current package.json version is: ', '[INFO]: Current package.json version is: ',
packageJson.version, packageJson.version,
) )
packageJson.version = newVersion.startsWith('v') const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1) ? newVersion.slice(1)
: newVersion : newVersion
await fs.writeFile( const updatedData = data.replace(
packageJsonPath, /^(\s*"version"\s*:\s*)"[^"]+"/m,
JSON.stringify(packageJson, null, 2), `$1"${versionWithoutV}"`,
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
) )
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) { } catch (error) {
console.error('Error updating package.json version:', error) console.error('Error updating package.json version:', error)
throw 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) { async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd() const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json') const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
@@ -168,13 +199,16 @@ async function updateTauriConfigVersion(newVersion) {
tauriConfig.version, tauriConfig.version,
) )
tauriConfig.version = versionWithoutV const updatedData = data.replace(
/^(\s*"version"\s*:\s*)"[^"]+"/m,
await fs.writeFile( `$1"${versionWithoutV}"`,
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
'utf8',
) )
if (updatedData === data) {
throw new Error('version field was not found in tauri.conf.json')
}
await fs.writeFile(tauriConfigPath, updatedData, 'utf8')
console.log( console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`, `[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
) )
@@ -239,6 +273,7 @@ async function main(versionArg) {
console.log(`[INFO]: Updating versions to: ${newVersion}`) console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion) await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion) await updateCargoVersion(newVersion)
await updateCargoLockVersion(newVersion)
await updateTauriConfigVersion(newVersion) await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!') console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) { } catch (error) {
+17 -19
View File
@@ -59,13 +59,13 @@ smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true } bitflags = { workspace = true }
socket2 = { workspace = true } socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] } warp = { version = "0.4.3", features = ["server"] }
open = "5.4.1" open = "5.4.4"
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] } rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] }
dirs = "7.0.0" dirs = "7.0.0"
dunce = "1.0.5" dunce = "1.0.5"
nanoid = "0.5" nanoid = "0.5"
chrono = { workspace = true } chrono = { workspace = true }
boa_engine = "0.21.1" boa_engine = "0.22.0"
once_cell = { version = "1.21.4", features = ["parking_lot"] } once_cell = { version = "1.21.4", features = ["parking_lot"] }
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
reqwest = { workspace = true } reqwest = { workspace = true }
@@ -74,37 +74,37 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
"guard", "guard",
] } ] }
network-interface = { version = "2.0.5", features = ["serde"] } network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.3.5" tauri-plugin-shell = "2.3.6"
tauri-plugin-opener = "2.5.4" tauri-plugin-opener = "2.5.5"
tauri-plugin-dialog = "2.7.2" tauri-plugin-dialog = "2.7.3"
tauri-plugin-fs = "2.5.1" tauri-plugin-fs = "2.5.2"
tauri-plugin-process = "2.3.1" tauri-plugin-process = "2.3.1"
tauri-plugin-deep-link = "2.4.9" tauri-plugin-deep-link = "2.4.10"
tauri-plugin-window-state = "2.4.1" tauri-plugin-window-state = "2.4.1"
zip = "8.6.0" zip = "8.6.0"
flate2 = "1.1.9" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.0" } aes-gcm = { version = "0.11.1" }
base64 = "0.23.1" base64 = "0.23.1"
sha2 = "0.11" sha2 = "0.11"
getrandom = "0.4.3" getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
gethostname = "1.1.0" gethostname = "1.1.0"
scopeguard = "1.2.0" scopeguard = "1.2.0"
tauri-plugin-notification = "2.3.3" tauri-plugin-notification = "2.4.0"
tokio-stream = "0.1.19" tokio-stream = "0.1.19"
backon = { version = "1.6.0", features = ["tokio-sleep"] } backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.5.9" tauri-plugin-http = "2.6.0"
tracing = { workspace = true } tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.1.0", optional = 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" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.9", version = "2.6.9", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.2", version = "2.7.2", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.2"
tokio-rustls = "0.26.4" tokio-rustls = "0.26.5"
rustls = { version = "0.23.43", features = ["ring"] } rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9" webpki-roots = "1.0.9"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
@@ -114,9 +114,7 @@ tokio-util = { version = "0.7.19", features = ["time"] }
libc = "0.2.189" libc = "0.2.189"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.3.0", optional = true } tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
core-foundation = "0.9"
system-configuration = "0.7"
objc2 = "0.6.4" objc2 = "0.6.4"
objc2-foundation = { version = "0.3.2", features = [ objc2-foundation = { version = "0.3.2", features = [
"NSString", "NSString",
@@ -150,7 +148,7 @@ windows-sys.workspace = true
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-autostart = "2.5.1" tauri-plugin-autostart = "2.5.1"
tauri-plugin-global-shortcut = "2.3.2" tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-updater = "2.10.1" tauri-plugin-updater = "2.11.0"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true } 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
+2 -1
View File
@@ -210,7 +210,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found")); 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 .await
.stringify_err() .stringify_err()
} }
+5
View File
@@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult {
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED")) .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
} }
#[tauri::command]
pub fn take_service_fallback_notice() -> bool {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command] #[tauri::command]
pub fn sync_runtime_providers() { pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
+61 -5
View File
@@ -185,6 +185,42 @@ where
apply_proxy().await apply_proxy().await
} }
#[cfg(target_os = "windows")]
async fn run_service_start_with_sidecar_fallback<Start, StartFuture, Fallback, FallbackFuture>(
is_admin: bool,
start_service: Start,
start_sidecar: Fallback,
) -> Result<()>
where
Start: FnOnce() -> StartFuture,
StartFuture: 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(());
};
// A transport failure cannot establish whether the service already started the core.
let location_refused = error
.downcast_ref::<crate::core::service::ServiceStartRefusal>()
.is_some_and(|refusal| {
refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16
});
if !is_admin || !location_refused {
return Err(error);
}
logging!(
warn,
Type::Core,
"service refused the core location while app is elevated; falling back to sidecar: {error:#}"
);
let reason = format!("{error:#}");
start_sidecar(error)
.await
.map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}")))
}
async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>( async fn run_sidecar_termination_transition<Clear, ClearFuture, Terminate>(
clear_proxy: Clear, clear_proxy: Clear,
terminate_sidecar: Terminate, terminate_sidecar: Terminate,
@@ -476,10 +512,7 @@ impl CoreManager {
// rather than fail — only if the watcher is actually live; a user toggling while // rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast. // offline still fails fast.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let watcher_armed = crate::core::network_watch::is_armed(); if crate::core::network_watch::is_armed()
#[cfg(not(target_os = "macos"))]
let watcher_armed = false;
if watcher_armed
&& !crate::utils::resolve::is_resolve_done() && !crate::utils::resolve::is_resolve_done()
&& Config::verge() && Config::verge()
.await .await
@@ -553,7 +586,30 @@ impl CoreManager {
} }
let result = match startup { let result = match startup {
StartupDecision::Service => self.start_core_by_service().await, StartupDecision::Service => {
#[cfg(target_os = "windows")]
{
run_service_start_with_sidecar_fallback(
crate::core::runstate::RUN_STATE.state().is_admin,
|| self.start_core_by_service(),
|error| async move {
use crate::core::runstate::RUN_STATE;
RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?;
let result = self.start_core_by_sidecar().await;
if result.is_err() {
SERVICE_MANAGER.withdraw_sidecar_allowance();
} else {
crate::core::service::notify_service_fallback();
}
result
},
)
.await
}
#[cfg(not(target_os = "windows"))]
self.start_core_by_service().await
}
StartupDecision::Sidecar => self.start_core_by_sidecar().await, StartupDecision::Sidecar => self.start_core_by_sidecar().await,
StartupDecision::Wait => Ok(()), StartupDecision::Wait => Ok(()),
}; };
+76 -50
View File
@@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _;
const SIDECAR_READINESS_ATTEMPTS: usize = 30; 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 { impl CoreManager {
/// Restores profile selections before callers enable the system proxy. /// Restores profile selections before callers enable the system proxy.
/// The bounded first pass continues in the background, and later calls supersede earlier ones. /// The bounded first pass continues in the background, and later calls supersede earlier ones.
@@ -170,33 +208,10 @@ impl CoreManager {
let pid = child.pid(); let pid = child.pid();
tracing::Span::current().record("pid", pid); tracing::Span::current().record("pid", pid);
let readiness = poll_sidecar_readiness(SIDECAR_READINESS_ATTEMPTS, SIDECAR_READINESS_INTERVAL, || async { // The sidecar has to be drained from the moment it starts. tauri-plugin-shell buffers a
tokio::time::timeout(SIDECAR_READINESS_PROBE_TIMEOUT, async { // single event, so an unread channel stalls its reader threads and the core blocks on a
handle::Handle::mihomo().get_version().await // full stdout pipe before it ever creates the API socket.
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
let core_readiness_generation = self.mark_core_ready(); let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match event { match event {
@@ -226,6 +241,32 @@ impl CoreManager {
} }
}); });
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
})
.await
.context("Mihomo readiness probe timed out")??;
Ok(())
})
.await;
if let Err(readiness_error) = readiness {
proxy_control::stop_guard().await;
self.core_stopped();
return match child.kill() {
Ok(()) => Err(readiness_error),
Err(kill_error) => Err(anyhow::anyhow!(
"{readiness_error:#}; failed to terminate unready sidecar PID {pid}: {kill_error:#}"
)),
};
}
#[cfg(target_os = "windows")]
self.set_job_handle(Some(job));
self.set_running_child_sidecar(child);
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
Ok(()) Ok(())
} }
@@ -269,30 +310,15 @@ impl CoreManager {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use crate::constants::timing; use crate::constants::timing;
let mut last_err = None; retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || {
for attempt in 0..timing::SERVICE_START_RETRIES { service::run_core_by_service(config_file)
match service::run_core_by_service(config_file).await { })
Ok(()) => { .await?;
self.mark_core_ready(); self.mark_core_ready();
self.core_started(RunningMode::Service); self.core_started(RunningMode::Service);
self.restore_selected_nodes().await; self.restore_selected_nodes().await;
service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY); service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY);
return Ok(()); 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")))
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
+27 -38
View File
@@ -5,53 +5,35 @@
use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler}; use crate::{config::Config, core::CoreManager, core::proxy_control, process::AsyncHandler};
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use core_foundation::{ use parking_lot::Mutex;
array::CFArray, use sysproxy::NetworkServiceMonitor;
runloop::{CFRunLoop, kCFRunLoopCommonModes},
string::CFString,
};
use std::sync::atomic::{AtomicBool, Ordering};
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder, SCDynamicStoreCallBackContext};
/// Set once the subscription is live. Startup defers the proxy write to the watcher only then. static MONITOR: Mutex<Option<NetworkServiceMonitor>> = Mutex::new(None);
static ARMED: AtomicBool = AtomicBool::new(false);
pub fn is_armed() -> bool { pub fn is_armed() -> bool {
ARMED.load(Ordering::Acquire) MONITOR.lock().is_some()
} }
/// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service /// Subscribe to the primary IPv4 service on the main run loop. Armed on return, so a service
/// appearing from here on is never missed. /// appearing from here on is never missed.
pub fn start() { pub fn start() {
let store = SCDynamicStoreBuilder::new("clash-verge-rev") match NetworkServiceMonitor::start(|| {
.callback_context(SCDynamicStoreCallBackContext { AsyncHandler::spawn(reapply);
callout: on_change, }) {
info: (), Ok(monitor) => {
}) *MONITOR.lock() = Some(monitor);
.build(); logging!(
let keys = CFArray::from_CFTypes(&[CFString::from_static_string("State:/Network/Global/IPv4")]); debug,
let Some(source) = store Type::Core,
.filter(|store| store.set_notification_keys(&keys, &CFArray::<CFString>::from_CFTypes(&[]))) "network watch armed; the system proxy is re-applied on network changes"
.and_then(|store| store.create_run_loop_source()) );
else { }
logging!( Err(error) => logging!(
warn, warn,
Type::Core, Type::Core,
"could not watch the network; the proxy is not re-applied on changes" "could not watch the network; the proxy is not re-applied on changes: {error}"
); ),
return; }
};
CFRunLoop::get_main().add_source(&source, unsafe { kCFRunLoopCommonModes });
ARMED.store(true, Ordering::Release);
logging!(
debug,
Type::Core,
"network watch armed; the system proxy is re-applied on network changes"
);
}
fn on_change(_: SCDynamicStore, _: CFArray<CFString>, (): &mut ()) {
AsyncHandler::spawn(reapply);
} }
async fn reapply() { async fn reapply() {
@@ -66,10 +48,17 @@ async fn reapply() {
.latest_arc() .latest_arc()
.enable_system_proxy .enable_system_proxy
.unwrap_or_default() .unwrap_or_default()
|| !proxy_control::has_network_service().await
{ {
return; return;
} }
if !proxy_control::has_network_service().await {
logging!(
debug,
Type::Core,
"network changed without a service to write the proxy on; waiting for the next change"
);
return;
}
if let Err(error) = manager.apply_proxy_after_start().await { if let Err(error) = manager.apply_proxy_after_start().await {
logging!( logging!(
warn, warn,
+4 -5
View File
@@ -385,12 +385,11 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result<MacosProxyConfig
} }
/// Whether macOS has a network service to write the proxy on right now. /// Whether macOS has a network service to write the proxy on right now.
#[cfg(target_os = "macos")]
pub async fn has_network_service() -> bool { pub async fn has_network_service() -> bool {
tokio::task::spawn_blocking( tokio::task::spawn_blocking(|| sysproxy::Sysproxy::has_network_service().unwrap_or(true))
|| !matches!(sysproxy::Sysproxy::get_system_proxy(), Err(error) if is_missing_network_service(&error)), .await
) .unwrap_or(true)
.await
.unwrap_or(true)
} }
pub fn is_reportable(error: &anyhow::Error) -> bool { pub fn is_reportable(error: &anyhow::Error) -> bool {
+15
View File
@@ -323,6 +323,21 @@ impl<E: RunStateEnv> RunStateStore<E> {
Ok(()) 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. /// Requests installation only for a confirmed absent Service.
pub fn require_install_for_session(&self) -> Result<()> { pub fn require_install_for_session(&self) -> Result<()> {
let mut state = self.service.lock(); let mut state = self.service.lock();
+150 -7
View File
@@ -39,6 +39,17 @@ use std::{
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None)); static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::sidecar_fallback", "");
}
pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
}
/// Capabilities of the service session that owns the running Core. /// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades. /// They are discarded with that session rather than cached across service upgrades.
@@ -564,8 +575,39 @@ fn uninstall_service() -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn install_service() -> Result<()> { fn install_service() -> Result<()> {
use std::process::Output;
logging!(info, Type::Service, "install service"); logging!(info, Type::Service, "install service");
let cores = crate::config::IVerge::VALID_CLASH_CORES
.iter()
.map(|core| service_core_path(core, ".exe"))
.collect::<Result<Vec<_>>>()?;
install_windows_service_with_cores(&cores, run_windows_service_installer)
}
#[cfg(target_os = "windows")]
fn install_windows_service_with_cores(
cores: &[PathBuf],
mut run_installer: impl FnMut(&[std::ffi::OsString]) -> Result<()>,
) -> Result<()> {
let cores: Vec<_> = cores.iter().filter(|core| core.is_file()).collect();
if cores.is_empty() {
bail!("no core executable is available; restore a bundled core before installing the service");
}
let mut arguments = vec!["--install-service".into()];
for core in cores {
let digest = sha256_hex(core)?;
arguments.extend([
"--install-core".into(),
core.as_os_str().to_owned(),
"--sha256".into(),
digest.into(),
]);
}
run_installer(&arguments).context("failed to install the service and its cores; choose Repair to retry")
}
#[cfg(target_os = "windows")]
fn run_windows_service_installer(arguments: &[std::ffi::OsString]) -> Result<()> {
use std::process::Output;
use deelevate::{PrivilegeLevel, Token}; use deelevate::{PrivilegeLevel, Token};
use runas::Command as RunasCommand; use runas::Command as RunasCommand;
@@ -583,7 +625,7 @@ fn install_service() -> Result<()> {
let level = token.privilege_level()?; let level = token.privilege_level()?;
let output = match level { let output = match level {
PrivilegeLevel::NotPrivileged => { PrivilegeLevel::NotPrivileged => {
let status = RunasCommand::new(&install_path).show(false).status()?; let status = RunasCommand::new(&install_path).args(arguments).show(false).status()?;
Output { Output {
status, status,
stdout: Vec::new(), stdout: Vec::new(),
@@ -592,7 +634,10 @@ fn install_service() -> Result<()> {
} }
_ => { _ => {
// StdCommand returns Output directly // StdCommand returns Output directly
StdCommand::new(&install_path).creation_flags(0x08000000).output()? StdCommand::new(&install_path)
.args(arguments)
.creation_flags(0x08000000)
.output()?
} }
}; };
@@ -1042,6 +1087,25 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result<Stage
.context("Clash Verge Service 未返回运行时暂存结果") .context("Clash Verge Service 未返回运行时暂存结果")
} }
#[derive(Debug)]
pub(super) struct ServiceStartRefusal {
pub code: u16,
pub core_path: String,
pub message: String,
}
impl std::fmt::Display for ServiceStartRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"failed to start Service core at {} (code {}): {}",
self.core_path, self.code, self.message
)
}
}
impl std::error::Error for ServiceStartRefusal {}
/// 尝试使用服务启动core /// 尝试使用服务启动core
#[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))] #[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))]
pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> { pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> {
@@ -1077,10 +1141,12 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
err_msg err_msg
); );
start_owner_monitor(); start_owner_monitor();
bail!( return Err(ServiceStartRefusal {
"failed to start Service core at {}: {err_msg}", code: response.code,
request.runtime.core_path core_path: request.runtime.core_path,
); message: err_msg,
}
.into());
} }
let result = response.data.context("Clash Verge Service 未返回会话信息")?; let result = response.data.context("Clash Verge Service 未返回会话信息")?;
@@ -1099,6 +1165,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`. // PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor(); start_owner_monitor();
tracing::Span::current().record("outcome", "started"); tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
logging!( logging!(
info, info,
Type::Service, Type::Service,
@@ -2167,6 +2234,82 @@ mod tests {
} }
} }
#[cfg(target_os = "windows")]
#[test]
fn windows_service_install_combines_service_and_attested_cores() -> anyhow::Result<()> {
use std::ffi::OsString;
let root = TestDirectory::new("scoop install")?;
let cores: Vec<_> = crate::config::IVerge::VALID_CLASH_CORES
.iter()
.map(|name| root.path().join(format!("{name}.exe")))
.collect();
for core in &cores {
std::fs::write(core, b"abc")?;
}
let mut calls = Vec::new();
super::install_windows_service_with_cores(&cores, |arguments| {
calls.push(arguments.to_vec());
Ok(())
})?;
assert_eq!(calls.len(), 1, "service and cores must share one elevation");
let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
let mut expected: Vec<OsString> = vec!["--install-service".into()];
expected.extend(cores.iter().flat_map(|core| {
[
"--install-core".into(),
core.as_os_str().to_owned(),
"--sha256".into(),
digest.into(),
]
}));
assert_eq!(calls[0], expected);
Ok(())
}
#[cfg(target_os = "windows")]
#[test]
fn windows_service_install_skips_unavailable_cores() -> anyhow::Result<()> {
let root = TestDirectory::new("missing-core")?;
let core = root.path().join("verge-mihomo.exe");
let missing = root.path().join("verge-mihomo-alpha.exe");
std::fs::write(&core, b"abc")?;
let cores = [core.clone(), missing.clone(), root.path().to_path_buf()];
let mut calls = Vec::new();
super::install_windows_service_with_cores(&cores, |arguments| {
calls.push(arguments.to_vec());
Ok(())
})?;
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].len(), 5);
assert_eq!(calls[0][0], "--install-service");
assert_eq!(calls[0][2], core.as_os_str());
let error = super::install_windows_service_with_cores(&[missing, root.path().to_path_buf()], |_| {
panic!("no available core must fail before invoking the installer")
})
.expect_err("installation requires at least one available core");
assert!(error.to_string().contains("no core executable"));
Ok(())
}
#[cfg(target_os = "windows")]
#[test]
fn windows_service_install_propagates_installer_failure() -> anyhow::Result<()> {
let root = TestDirectory::new("install-failure")?;
let core = root.path().join("verge-mihomo.exe");
std::fs::write(&core, b"abc")?;
let mut calls = 0;
let result = super::install_windows_service_with_cores(std::slice::from_ref(&core), |_| {
calls += 1;
bail!("installer failed with exit code 1")
});
let error = result.expect_err("installation failure must not be reported as success");
assert!(format!("{error:#}").contains("exit code 1"));
assert!(error.to_string().contains("Repair"));
assert_eq!(calls, 1);
Ok(())
}
fn staging_directory(home: &Path) -> PathBuf { fn staging_directory(home: &Path) -> PathBuf {
home.join("Applications/.clash-verge-rev-dev/service-core") home.join("Applications/.clash-verge-rev-dev/service-core")
} }
+72 -50
View File
@@ -284,8 +284,7 @@ fn process_seq_items(
config config
} }
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。 /// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
const CONTROL_PLANE_KEYS: &[&str] = &[ const CONTROL_PLANE_KEYS: &[&str] = &[
"external-controller", "external-controller",
#[cfg(unix)] #[cfg(unix)]
@@ -308,25 +307,20 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
"unified-delay", "unified-delay",
]; ];
/// The fields the app owns, held across the stages that apply the user's manual overrides. /// App-owned fields: captured after the app derives them, enforced after every override.
///
/// Capturing and restoring are one value rather than two calls because the order is a
/// correctness requirement, not a style: capture must happen after the app has finished
/// deriving these fields and before any override runs, and restore must happen after every
/// override. As four separate calls that contract lived only in comments.
struct AuthoritativeFields { struct AuthoritativeFields {
control_plane: Mapping, control_plane: Mapping,
tun: Mapping, tun: Mapping,
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely. /// Only when the DNS page switched it on.
dns_ipv6: Option<Value>, dns_ipv6: Option<Value>,
} }
impl AuthoritativeFields { impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self { fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
Self { Self {
control_plane: snapshot_control_plane(config), control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys), tun: snapshot_tun(config, gui_tun_keys),
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(), dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
} }
} }
@@ -401,7 +395,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
config config
} }
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。 /// 只在 DNS 页开启 IPv6 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> { fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned() config.get("dns")?.get("ipv6").cloned()
} }
@@ -481,11 +475,7 @@ async fn process_profile_items(
(config, exists_keys, result_map) (config, exists_keys, result_map)
} }
/// Merge the Application Merge Config over the profile's own configuration. /// Merges the app's clash config over the profile; switches are parameters to keep it pure.
///
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
/// out of the global config from inside its loop, which made a stage that looks pure depend
/// on process state and left it untestable.
fn merge_default_config( fn merge_default_config(
mut config: Mapping, mut config: Mapping,
clash_config: Mapping, clash_config: Mapping,
@@ -683,9 +673,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
config config
} }
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`, /// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
fn ensure_fake_ip_range6(dns: &mut Mapping) { fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value; use serde_yaml_ng::Value;
@@ -708,38 +696,77 @@ 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,
}
}
/// Overlay set fields onto base; maps merge per key, lists are leaves.
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
for (key, value) in overlay {
let value = match (base.get_mut(&key), value) {
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
*existing = merge_set_fields(std::mem::take(existing), patch);
continue;
}
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
(_, value) => value,
};
if is_set(&value) {
base.insert(key, value);
}
}
base
}
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()
}
/// Returns the merged config and whether the page set `dns.ipv6`.
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
let mut owns_ipv6 = false;
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() { if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG); let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists() if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await && 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(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
{ {
if let Some(hosts_value) = dns_config.get("hosts") if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
&& hosts_value.is_mapping() let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
{ config.insert("hosts".into(), hosts.into());
config.insert("hosts".into(), hosts_value.clone());
logging!(debug, Type::Core, "apply hosts configuration"); logging!(debug, Type::Core, "apply hosts configuration");
} }
if let Some(dns_value) = dns_config.get("dns") { // Legacy layout: no `dns` root.
if let Some(dns_mapping) = dns_value.as_mapping() { let dns_override = match dns_config.remove("dns") {
let mut dns_mapping = dns_mapping.clone(); Some(Value::Mapping(dns)) => Some(dns),
ensure_fake_ip_range6(&mut dns_mapping); Some(_) => None,
config.insert("dns".into(), dns_mapping.into()); None => Some(dns_config),
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)"); };
} if let Some(dns_override) = dns_override {
} else { owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
let mut dns_config = dns_config; let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
ensure_fake_ip_range6(&mut dns_config); ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns_config.into()); config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml"); logging!(debug, Type::Core, "apply dns_config.yaml");
} }
} }
} }
config (config, owns_ipv6)
} }
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision. /// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
@@ -800,9 +827,9 @@ pub async fn enhance(
let config = apply_builtin_scripts(config, clash_core, enable_builtin); let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun); let config = use_tun(config, enable_tun);
let config = apply_dns_settings(config, enable_dns_settings).await; let (config, owns_dns_ipv6) = 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, owns_dns_ipv6);
let (config, exists_keys, result_map) = process_global_items( let (config, exists_keys, result_map) = process_global_items(
config, config,
@@ -864,8 +891,7 @@ mod fake_ip_tests {
#[test] #[test]
fn a_hand_edited_empty_range_counts_as_missing() { fn a_hand_edited_empty_range_counts_as_missing() {
// The reason this is not just a `contains_key` check: YAML edited by hand often // Hand-edited YAML may leave the key present but empty.
// leaves the key present with nothing after the colon.
for blank in ["", " "] { for blank in ["", " "] {
let mut config = dns(&[ let mut config = dns(&[
("ipv6", Value::from(true)), ("ipv6", Value::from(true)),
@@ -1004,8 +1030,7 @@ mod use_tun_tests {
#[tokio::test] #[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() { async fn switching_tun_off_leaves_dns_untouched() {
// Turning TUN off must not rewrite DNS the user or profile chose; only the system // TUN off must not rewrite the DNS the profile chose.
// resolver is restored, which is not part of the configuration.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]); let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false); let result = use_tun(config, false);
@@ -1038,8 +1063,7 @@ mod use_sort_tests {
#[test] #[test]
fn the_bulky_list_fields_are_written_last() { fn the_bulky_list_fields_are_written_last() {
// These are the fields that make a config file unreadable from the top, so they are // Bulky list fields go last so the top of the file stays readable.
// pushed below the settings a human actually scans for.
let mut config = Mapping::new(); let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules")); config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule")); config.insert(Value::from("mode"), Value::from("rule"));
@@ -1129,8 +1153,7 @@ mod merge_default_config_tests {
#[test] #[test]
fn a_disabled_listener_is_removed_rather_than_merged() { fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not just skipped: the profile may have asked for the port itself, and a // Removed, not skipped: the profile's own value must not reopen a disabled listener.
// listener the user switched off must not be opened by the profile's own value.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]); let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]); let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
@@ -1152,8 +1175,7 @@ mod merge_default_config_tests {
#[test] #[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() { 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 // Blanked, not removed: an absent key makes mihomo listen on its default.
// listen anyway, which is the opposite of what switching it off means.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]); let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false); let merged = merge(Mapping::new(), clash_config, false);
+1
View File
@@ -163,6 +163,7 @@ mod app_init {
cmd::apply_dns_config, cmd::apply_dns_config,
cmd::set_dns_override, cmd::set_dns_override,
cmd::take_dns_override_notice, cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::get_dns_config_content, cmd::get_dns_config_content,
cmd::validate_dns_config, cmd::validate_dns_config,
cmd::get_clash_logs, cmd::get_clash_logs,
+4 -17
View File
@@ -228,27 +228,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Raise existing sub-floor update intervals, once per install. /// Once per install; the marker is written even when no interval needed raising.
///
/// 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.
pub async fn migrate_short_update_intervals() -> Result<()> { pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?; let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? { if fs::try_exists(&marker).await? {
return Ok(()); return Ok(());
} }
// Same order as every other writer holding it (lock, then draft permit): save_file runs // Same lock order as other writers; saving inside the closure keeps a racing restore intact.
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await; let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
let min = constants::profile::MIN_UPDATE_INTERVAL; let min = constants::profile::MIN_UPDATE_INTERVAL;
let raised = Config::profiles() let raised = Config::profiles()
.await .await
.with_data_modify(|mut profiles: IProfiles| async move { .with_data_modify(|mut profiles: IProfiles| async move {
// `IProfiles::new` normalises items to Some when it read the file, and returns a // A failed profiles load (bare default) must not burn the one-shot marker.
// 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.
if profiles.items.is_none() { if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done"); anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
} }
@@ -370,14 +364,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
"nameserver-policy".into(), "nameserver-policy".into(),
Value::Mapping(serde_yaml_ng::Mapping::new()), Value::Mapping(serde_yaml_ng::Mapping::new()),
), ),
( ("proxy-server-nameserver".into(), Value::Sequence(vec![])),
"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()),
]),
),
("direct-nameserver".into(), Value::Sequence(vec![])), ("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)), ("direct-nameserver-follow-policy".into(), Value::Bool(false)),
( (
+2 -1
View File
@@ -15,11 +15,12 @@
}, },
"rpm": { "rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"], "depends": ["openssl", "libayatana-appindicator-gtk3"],
"recommends": ["policycoreutils", "policycoreutils-python-utils"],
"desktopTemplate": "./packages/linux/clash-verge.desktop", "desktopTemplate": "./packages/linux/clash-verge.desktop",
"provides": ["clash-verge"], "provides": ["clash-verge"],
"conflicts": ["clash-verge"], "conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"], "obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh", "postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh" "preRemoveScript": "./packages/linux/pre-remove.sh"
} }
}, },
+16 -3
View File
@@ -13,12 +13,24 @@ interface SortableItemProps {
index: number index: number
group?: string group?: string
disabled?: boolean disabled?: boolean
// 用于给 tanstack virtual 列表测量当前拖动元素尺寸
measureElement?: (element: HTMLDivElement | null) => void
dataIndex?: number
style?: CSSProperties style?: CSSProperties
children: ReactNode | ((props: SortableItemRenderProps) => ReactNode) children: ReactNode | ((props: SortableItemRenderProps) => ReactNode)
} }
export const SortableItem = (props: SortableItemProps) => { 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({ const { ref, handleRef, sortable, isDragging } = useSortable({
id, id,
index, index,
@@ -30,8 +42,9 @@ export const SortableItem = (props: SortableItemProps) => {
(element: HTMLDivElement | null) => { (element: HTMLDivElement | null) => {
handleRef(element?.querySelector('[data-sortable-handle]') ?? null) handleRef(element?.querySelector('[data-sortable-handle]') ?? null)
ref(element) ref(element)
measureElement?.(element)
}, },
[handleRef, ref], [handleRef, ref, measureElement],
) )
const mergedStyle: CSSProperties = { const mergedStyle: CSSProperties = {
@@ -53,7 +66,7 @@ export const SortableItem = (props: SortableItemProps) => {
} }
return ( return (
<div ref={setRef} style={mergedStyle}> <div ref={setRef} data-index={dataIndex} style={mergedStyle}>
{children} {children}
</div> </div>
) )
@@ -35,7 +35,7 @@ export const formatConnectionTraffic = (value?: number) => {
? 0 ? 0
: Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1) : Math.min(Math.floor(Math.log2(value) / 10), TRAFFIC_UNITS.length - 1)
const data = value / 1024 ** exp const data = value / 1024 ** exp
const text = data >= 1000 ? data.toFixed(0) : data.toPrecision(3) const text = Math.round(data) >= 1000 ? data.toFixed(0) : data.toPrecision(3)
return `${text} ${TRAFFIC_UNITS[exp]}` return `${text} ${TRAFFIC_UNITS[exp]}`
} }
+320 -214
View File
@@ -1,6 +1,7 @@
/* eslint-disable @eslint-react/set-state-in-effect */ /* eslint-disable @eslint-react/set-state-in-effect */
import { import {
AccessTimeRounded, AccessTimeRounded,
ArrowDropDown,
ChevronRight, ChevronRight,
NetworkCheckRounded, NetworkCheckRounded,
WifiOff as SignalError, WifiOff as SignalError,
@@ -15,25 +16,22 @@ import {
import { import {
Box, Box,
Button, Button,
ButtonBase,
Chip, Chip,
ClickAwayListener, ClickAwayListener,
FormControl,
IconButton, IconButton,
InputLabel,
type MenuProps,
MenuItem, MenuItem,
MenuList, MenuList,
Paper, Paper,
Popper, Popper,
Select, type PopperProps,
type SelectChangeEvent,
Tooltip, Tooltip,
Typography, Typography,
alpha, alpha,
useTheme, useTheme,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' 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 { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router' 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_DEFAULT_INTERVAL_MINUTES = 5
const AUTO_CHECK_INITIAL_DELAY_MS = 100 const AUTO_CHECK_INITIAL_DELAY_MS = 100
const PROXY_MENU_MAX_HEIGHT = 500 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 { interface ProxyOption {
memberIndex: number memberIndex: number
@@ -163,34 +195,6 @@ const optionValue = (memberIndex: number, member: ResolvedProxyMember) =>
member.kind === 'node' ? member.node.recordId : member.ref.name 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 = ( const sortProxyOptions = (
options: ProxyOption[], options: ProxyOption[],
sortType: ProxySortType, sortType: ProxySortType,
@@ -216,58 +220,71 @@ const sortProxyOptions = (
.map(({ option }) => option) .map(({ option }) => option)
} }
interface PersistentProxySelectProps { interface PersistentSelectProps {
label: string label: string
groupName: string displayValue: React.ReactNode
fixed?: string
value: string
selectedName: string
selectedDelay: number
options: ProxyOption[]
open: boolean open: boolean
disabled: boolean disabled: boolean
keepOpenRef: React.RefObject<HTMLElement | null> keepOpenRef: React.RefObject<HTMLElement | null>
onOpen: () => void onOpenChange: (open: boolean) => void
onClose: () => void renderOptions: () => React.ReactNode
onChange: (value: string) => void
} }
const PersistentProxySelect = ({ const PersistentSelect = ({
label, label,
groupName, displayValue,
fixed,
value,
selectedName,
selectedDelay,
options,
open, open,
disabled, disabled,
keepOpenRef, keepOpenRef,
onOpen, onOpenChange,
onClose, renderOptions,
onChange, }: PersistentSelectProps) => {
}: PersistentProxySelectProps) => { const id = useId()
const anchorRef = useRef<HTMLDivElement>(null) const labelId = `${id}-label`
const listboxId = 'current-proxy-node-listbox' const valueId = `${id}-value`
const labelId = 'proxy-select-label' const listboxId = `${id}-listbox`
const fixedProxyInUsed = selectedName === fixed 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(() => { useEffect(() => {
if (!open) return if (!open) return
const closeFromKeyboard = (event: KeyboardEvent) => { const closeFromKeyboard = (event: KeyboardEvent) => {
if (event.key === 'Tab') { if (event.key === 'Tab') {
onClose() onOpenChange(false)
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
onClose() onOpenChange(false)
anchorRef.current anchorRef.current?.focus({ preventScroll: true })
?.querySelector<HTMLElement>('[role="combobox"]')
?.focus()
} }
} }
document.addEventListener('keydown', closeFromKeyboard) document.addEventListener('keydown', closeFromKeyboard)
return () => document.removeEventListener('keydown', closeFromKeyboard) return () => document.removeEventListener('keydown', closeFromKeyboard)
}, [onClose, open]) }, [onOpenChange, open])
return ( return (
<ClickAwayListener <ClickAwayListener
@@ -277,134 +294,119 @@ const PersistentProxySelect = ({
if (target instanceof Node && keepOpenRef.current?.contains(target)) { if (target instanceof Node && keepOpenRef.current?.contains(target)) {
return return
} }
onClose() onOpenChange(false)
}} }}
> >
<Box> <Box>
<FormControl ref={anchorRef} fullWidth variant="outlined" size="small"> <Box
<InputLabel id={labelId}>{label}</InputLabel> sx={{
<Select position: 'relative',
labelId={labelId} borderRadius: 1,
value={value} '& fieldset': {
open={false} borderColor: (theme) =>
onOpen={onOpen} alpha(theme.palette.text.primary, disabled ? 0.12 : 0.23),
label={label} },
...(!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} disabled={disabled}
renderValue={() => ( onClick={() => onOpenChange(!open)}
<Box onKeyDown={(event) => {
sx={{ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
display: 'flex', event.preventDefault()
justifyContent: 'space-between', onOpenChange(true)
alignItems: 'center', }
}} }}
> sx={{
<Typography noWrap>{selectedName}</Typography> width: '100%',
<Chip height: 40,
size="small" px: 1.75,
label={delayManager.formatDelay(selectedDelay)} borderRadius: 'inherit',
color={convertDelayColor(selectedDelay)} justifyContent: 'space-between',
/> textAlign: 'left',
{fixedProxyInUsed && ( color: disabled ? 'text.disabled' : 'text.primary',
<span
style={{
position: 'absolute',
fontSize: '12px',
top: '-3px',
right: '25px',
}}
>
📌
</span>
)}
</Box>
)}
SelectDisplayProps={{
'aria-controls': open ? listboxId : undefined,
'aria-expanded': open ? 'true' : 'false',
}} }}
> >
<MenuItem value={value}>{selectedName}</MenuItem> <Box id={valueId} sx={{ minWidth: 0, flex: 1 }}>
</Select> {displayValue}
</FormControl> </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 <Popper
open={open} open={open}
anchorEl={anchorRef.current} anchorEl={anchorRef.current}
placement="bottom-start" placement="bottom-start"
modifiers={proxyMenuModifiers}
popperOptions={popperOptions}
sx={{ sx={{
width: anchorRef.current?.clientWidth, visibility: 'hidden',
zIndex: (theme) => theme.zIndex.modal, zIndex: (theme) => theme.zIndex.modal,
}} }}
> >
<Paper <Paper
elevation={8} 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 <MenuList
ref={listRef}
id={listboxId} id={listboxId}
role="listbox" role="listbox"
aria-labelledby={labelId} aria-labelledby={labelId}
autoFocusItem
variant="selectedMenu" variant="selectedMenu"
> >
{options.map((option) => { {open && renderOptions()}
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>
)
})}
</MenuList> </MenuList>
</Paper> </Paper>
</Popper> </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 = () => { export const CurrentProxyCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -497,6 +617,11 @@ export const CurrentProxyCard = () => {
const [selectedGroupName, setSelectedGroupName] = useState('') const [selectedGroupName, setSelectedGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null) const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
useEffect(() => {
setOpenSelect(null)
}, [mode])
const delayButtonRef = useRef<HTMLButtonElement>(null) const delayButtonRef = useRef<HTMLButtonElement>(null)
const delays = useGroupDelays(selectedGroupName || null) const delays = useGroupDelays(selectedGroupName || null)
@@ -621,9 +746,8 @@ export const CurrentProxyCard = () => {
latestProxyMemberRef.current = currentOption?.member ?? null latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback( const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => { (newGroupName: string) => {
if (isGlobalMode || isDirectMode) return if (isGlobalMode || isDirectMode) return
const newGroupName = event.target.value
setSelectedGroupName(newGroupName) setSelectedGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName) writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
}, },
@@ -785,24 +909,9 @@ export const CurrentProxyCard = () => {
refreshProxy() 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( const proxyOptions = useMemo(
() => () =>
isDirectMode isDirectMode || openSelect !== 'proxy'
? [] ? []
: sortProxyOptions( : sortProxyOptions(
unsortedProxyOptions, unsortedProxyOptions,
@@ -813,6 +922,7 @@ export const CurrentProxyCard = () => {
[ [
delays, delays,
isDirectMode, isDirectMode,
openSelect,
unsortedProxyOptions, unsortedProxyOptions,
sortType, sortType,
defaultLatencyTimeout, defaultLatencyTimeout,
@@ -975,32 +1085,29 @@ export const CurrentProxyCard = () => {
/> />
)} )}
</Box> </Box>
<FormControl <Box sx={{ mb: 1.5 }}>
fullWidth <PersistentSelect
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')}
label={t('home.components.currentProxy.labels.group')} label={t('home.components.currentProxy.labels.group')}
displayValue={<Typography noWrap>{selectedGroupName}</Typography>}
open={openSelect === 'group'}
disabled={isGlobalMode || isDirectMode} disabled={isGlobalMode || isDirectMode}
MenuProps={{ onClose: handleGroupMenuClose }} keepOpenRef={delayButtonRef}
> onOpenChange={(open) => setOpenSelect(open ? 'group' : null)}
{selectableGroups.map((group) => ( renderOptions={() =>
<MenuItem key={group.name} value={group.name}> selectableGroups.map((group) => (
{group.name} <MenuItem
</MenuItem> key={group.name}
))} role="option"
</Select> aria-selected={group.name === selectedGroupName}
</FormControl> selected={group.name === selectedGroupName}
onClick={() => handleGroupChange(group.name)}
>
<Typography noWrap>{group.name}</Typography>
</MenuItem>
))
}
/>
</Box>
<PersistentProxySelect <PersistentProxySelect
label={t('home.components.currentProxy.labels.proxy')} label={t('home.components.currentProxy.labels.proxy')}
@@ -1013,12 +1120,11 @@ export const CurrentProxyCard = () => {
} }
selectedName={selectedProxyName} selectedName={selectedProxyName}
selectedDelay={currentDelay} selectedDelay={currentDelay}
options={isDirectMode ? [] : proxyOptions} options={proxyOptions}
open={openSelect === 'proxy'} open={openSelect === 'proxy'}
disabled={isDirectMode} disabled={isDirectMode}
keepOpenRef={delayButtonRef} keepOpenRef={delayButtonRef}
onOpen={() => setOpenSelect('proxy')} onOpenChange={(open) => setOpenSelect(open ? 'proxy' : null)}
onClose={() => setOpenSelect(null)}
onChange={handleProxyChange} onChange={handleProxyChange}
/> />
</Box> </Box>
+2 -1
View File
@@ -27,6 +27,7 @@ import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context' import { useAppRefreshers } from '@/providers/app-data-context'
import { updateProfile } from '@/services/cmds' import { updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url' import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic' import parseTraffic from '@/utils/parse-traffic'
@@ -41,7 +42,7 @@ const round = keyframes`
// 辅助函数解析URL和过期时间 // 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => { const parseUrl = (url?: string) => {
if (!url) return '-' if (!url) return '-'
if (url.startsWith('http')) return new URL(url).host if (isValidUrl(url)) return new URL(url).host
return 'local' return 'local'
} }
+2 -1
View File
@@ -108,7 +108,8 @@ export const SystemInfoCard = () => {
goToSettings() goToSettings()
} }
} catch (err) { } catch (err) {
showNotice.error(err) console.warn('update check failed:', err)
showNotice.warning(err)
} }
}) })
@@ -20,6 +20,7 @@ import {
type ServiceRequest, type ServiceRequest,
type ServiceRequestReason, type ServiceRequestReason,
} from '@/services/service-request' } from '@/services/service-request'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'restartOnly' type Remedy = 'installAndRestart' | 'restartOnly'
@@ -98,19 +99,25 @@ export const SysproxyPrivilegeDialog = () => {
setStep('restarting') setStep('restarting')
await restartCore() await restartCore()
// Verify that restart actually moved the core into the service.
const runState = await getRuntimeState() const runState = await getRuntimeState()
if (runState.mode === 'Service') { const usingAdminFallback =
// Retry the original request now that the service can apply it. getSystem() === 'windows' &&
runState.mode === 'Sidecar' &&
runState.isAdmin &&
runState.sidecarAllowed &&
runState.service === 'unavailable'
if (runState.mode === 'Service' || usingAdminFallback) {
if (restoring !== undefined) { if (restoring !== undefined) {
setStep('applying') setStep('applying')
await patchVergeConfig(restoring) await patchVergeConfig(restoring)
} }
showNotice.success( if (!usingAdminFallback) {
restoring === undefined showNotice.success(
? 'settings.sections.proxyControl.messages.installedCheckProxy' restoring === undefined
: 'settings.sections.proxyControl.messages.installedProxyRestored', ? 'settings.sections.proxyControl.messages.installedCheckProxy'
) : 'settings.sections.proxyControl.messages.installedProxyRestored',
)
}
close() close()
} else { } else {
showNotice.error( showNotice.error(
@@ -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 { 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 { import {
VerticalAlignBottomRounded, VerticalAlignBottomRounded,
VerticalAlignTopRounded, VerticalAlignTopRounded,
@@ -43,13 +35,7 @@ import {
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { GroupItem } from '@/components/profile/group-item' import { GroupItem } from '@/components/profile/group-item'
import { import {
getNetworkInterfaces, getNetworkInterfaces,
@@ -63,6 +49,12 @@ import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family' import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { parseYamlSafe } from '@/utils/yaml' import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props { interface Props {
proxiesUid: string proxiesUid: string
mergeUid: string mergeUid: string
@@ -203,106 +195,71 @@ export const GroupsEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => { const items = useMemo(
const shift = filteredPrependSeq.length > 0 ? 1 : 0 () =>
if (filteredPrependSeq.length > 0 && index === 0) { 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 ( return (
<> <GroupItem
{filteredPrependSeq.map((item, itemIndex) => { type={isDeleted ? 'delete' : 'original'}
return ( group={item}
<SortableItem onDelete={() => {
key={item.name} if (isDeleted) {
id={`prepend:${item.name}`} setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
index={itemIndex} } else {
group="prepend" setDeleteSeq((prev) => [...prev, item.name])
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'
} }
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 ( return (
<> <GroupItem
{filteredAppendSeq.map((item, itemIndex) => { type="prepend"
return ( group={item}
<SortableItem onDelete={() => {
key={item.name} setPrependSeq(prependSeq.filter((v) => v.name !== 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>
)
})}
</>
) )
} }
return (
<GroupItem
type="append"
group={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
} }
const onPrependDragEnd = async (event: DragEndEvent) => { const onReorder = (
const { operation, canceled } = event category: 'prepend' | 'append',
const { source, target } = operation activeIndex: number,
if (canceled || !target || !isSortable(source)) return overIndex: number,
) => {
const { index: overIndex, initialIndex: activeIndex } = source.sortable const list = category === 'prepend' ? prependSeq : appendSeq
const activeRealIndex = findRealIndex( const filtered =
prependSeq, category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
filteredPrependSeq, const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
activeIndex, const activeRealIndex = findRealIndex(list, filtered, activeIndex)
) const overRealIndex = findRealIndex(list, filtered, overIndex)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if ( if (
activeRealIndex < 0 || activeRealIndex < 0 ||
overRealIndex < 0 || overRealIndex < 0 ||
@@ -311,55 +268,9 @@ export const GroupsEditorViewer = (props: Props) => {
return 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))
} }
// 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 () => { const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property) const data = await readProfileFile(property)
@@ -1152,22 +1063,12 @@ export const GroupsEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider <GroupedVirtualList
sensors={[PointerSensor, KeyboardSensor]} items={items}
onDragOver={onDragOver} renderItem={renderItem}
onDragEnd={onDragEnd} onReorder={onReorder}
> style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
<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>
</List> </List>
</> </>
) : ( ) : (
+2 -3
View File
@@ -43,6 +43,7 @@ import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states' import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys' import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url' import { openExternalUrl } from '@/utils/open-external-url'
import parseTraffic from '@/utils/parse-traffic' import parseTraffic from '@/utils/parse-traffic'
@@ -965,9 +966,7 @@ export const ProfileItem = memo(ProfileItemBase)
function parseUrl(url?: string) { function parseUrl(url?: string) {
if (!url) return '' if (!url) return ''
const regex = /https?:\/\/(.+?)\// return isValidUrl(url) ? new URL(url).host : 'local file'
const result = url.match(regex)
return result ? result[1] : 'local file'
} }
function parseExpire(expire?: number) { function parseExpire(expire?: number) {
+71 -166
View File
@@ -1,12 +1,4 @@
import { arrayMove } from '@dnd-kit/helpers' 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 { import {
VerticalAlignBottomRounded, VerticalAlignBottomRounded,
VerticalAlignTopRounded, VerticalAlignTopRounded,
@@ -35,12 +27,7 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { BaseSearchBox, MonacoEditor } from '@/components/base'
BaseSearchBox,
MonacoEditor,
SortableItem,
VirtualList,
} from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item' import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds' import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' 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 parseUri from '@/utils/uri-parser'
import { parseYamlSafe } from '@/utils/yaml' import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props { interface Props {
profileUid: string profileUid: string
property: string property: string
@@ -107,106 +100,71 @@ export const ProxiesEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => { const items = useMemo(
const shift = filteredPrependSeq.length > 0 ? 1 : 0 () =>
if (filteredPrependSeq.length > 0 && index === 0) { 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 ( return (
<> <ProxyItem
{filteredPrependSeq.map((item, itemIndex) => { type={isDeleted ? 'delete' : 'original'}
return ( proxy={item}
<SortableItem onDelete={() => {
key={item.name} if (isDeleted) {
id={`prepend:${item.name}`} setDeleteSeq(deleteSeq.filter((v) => v !== item.name))
index={itemIndex} } else {
group="prepend" setDeleteSeq((prev) => [...prev, item.name])
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'
} }
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 ( return (
<> <ProxyItem
{filteredAppendSeq.map((item, itemIndex) => { type="prepend"
return ( proxy={item}
<SortableItem onDelete={() => {
key={item.name} setPrependSeq(prependSeq.filter((v) => v.name !== 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>
)
})}
</>
) )
} }
return (
<ProxyItem
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(appendSeq.filter((v) => v.name !== item.name))
}}
/>
)
} }
const onPrependDragEnd = async (event: DragEndEvent) => { const onReorder = (
const { operation, canceled } = event category: 'prepend' | 'append',
const { source, target } = operation activeIndex: number,
if (canceled || !target || !isSortable(source)) return overIndex: number,
) => {
const { index: overIndex, initialIndex: activeIndex } = source.sortable const list = category === 'prepend' ? prependSeq : appendSeq
const activeRealIndex = findRealIndex( const filtered =
prependSeq, category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
filteredPrependSeq, const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
activeIndex, const activeRealIndex = findRealIndex(list, filtered, activeIndex)
) const overRealIndex = findRealIndex(list, filtered, overIndex)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if ( if (
activeRealIndex < 0 || activeRealIndex < 0 ||
overRealIndex < 0 || overRealIndex < 0 ||
@@ -215,52 +173,9 @@ export const ProxiesEditorViewer = (props: Props) => {
return 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 // 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => { const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = [] const proxies: IProxyConfig[] = []
@@ -522,22 +437,12 @@ export const ProxiesEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider <GroupedVirtualList
sensors={[PointerSensor, KeyboardSensor]} items={items}
onDragOver={onDragOver} renderItem={renderItem}
onDragEnd={onDragEnd} onReorder={onReorder}
> style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
<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>
</List> </List>
</> </>
) : ( ) : (
+83 -177
View File
@@ -1,11 +1,4 @@
import { import { arrayMove } from '@dnd-kit/helpers'
DragDropProvider,
type DragOverEvent,
KeyboardSensor,
PointerSensor,
type DragEndEvent,
} from '@dnd-kit/react'
import { isSortable, isSortableOperation } from '@dnd-kit/react/sortable'
import { import {
VerticalAlignBottomRounded, VerticalAlignBottomRounded,
VerticalAlignTopRounded, VerticalAlignTopRounded,
@@ -36,13 +29,7 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
BaseSearchBox,
MonacoEditor,
SortableItem,
Switch,
VirtualList,
} from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item' import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds' import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
@@ -54,6 +41,12 @@ import getSystem from '@/utils/get-system'
import { isValidIpCidr } from '@/utils/network' import { isValidIpCidr } from '@/utils/network'
import { parseYamlSafe } from '@/utils/yaml' import { parseYamlSafe } from '@/utils/yaml'
import {
buildGroupedItems,
type GroupedVirtualItem,
GroupedVirtualList,
} from './grouped-virtual-list'
interface Props { interface Props {
groupsUid: string groupsUid: string
mergeUid: string mergeUid: string
@@ -302,117 +295,83 @@ export const RulesEditorViewer = (props: Props) => {
[appendSeq, match], [appendSeq, match],
) )
const renderItem = (index: number): React.ReactNode => { const items = useMemo(
const shift = filteredPrependSeq.length > 0 ? 1 : 0 () =>
if (filteredPrependSeq.length > 0 && index === 0) { 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 ( return (
<> <RuleItem
{filteredPrependSeq.map((item, itemIndex) => { type={isDeleted ? 'delete' : 'original'}
return ( ruleRaw={item}
<SortableItem onDelete={() => {
key={item} if (isDeleted) {
id={`prepend:${item}`} setDeleteSeq(deleteSeq.filter((v) => v !== item))
index={itemIndex} } else {
group="prepend" setDeleteSeq((prev) => [...prev, item])
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'
} }
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 ( return (
<> <RuleItem
{filteredAppendSeq.map((item, itemIndex) => { type="prepend"
return ( ruleRaw={item}
<SortableItem onDelete={() => {
key={item} setPrependSeq(prependSeq.filter((v) => v !== item))
id={`append:${item}`} }}
index={itemIndex} onAppend={() => {
group="append" setAppendSeq((prev) =>
style={{ margin: '8px 0' }} prev.includes(item) ? prev : [...prev, item],
>
<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>
) )
})} 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 onReorder = (
const result = Array.from(list) category: 'prepend' | 'append',
const [removed] = result.splice(startIndex, 1) activeIndex: number,
result.splice(endIndex, 0, removed) overIndex: number,
return result ) => {
} const list = category === 'prepend' ? prependSeq : appendSeq
const onPrependDragEnd = async (event: DragEndEvent) => { const filtered =
const { operation, canceled } = event category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
const { source, target } = operation const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
if (canceled || !target || !isSortable(source)) return const activeRealIndex = findRealIndex(list, filtered, activeIndex)
const overRealIndex = findRealIndex(list, filtered, overIndex)
const { index: overIndex, initialIndex: activeIndex } = source.sortable
const activeRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
activeIndex,
)
const overRealIndex = findRealIndex(
prependSeq,
filteredPrependSeq,
overIndex,
)
if ( if (
activeRealIndex < 0 || activeRealIndex < 0 ||
overRealIndex < 0 || overRealIndex < 0 ||
@@ -421,52 +380,9 @@ export const RulesEditorViewer = (props: Props) => {
return 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 () => { const fetchContent = useCallback(async () => {
hasLoadedSeqConfigRef.current = false hasLoadedSeqConfigRef.current = false
const data = await readProfileFile(property) const data = await readProfileFile(property)
@@ -854,22 +770,12 @@ export const RulesEditorViewer = (props: Props) => {
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<DragDropProvider <GroupedVirtualList
sensors={[PointerSensor, KeyboardSensor]} items={items}
onDragOver={onDragOver} renderItem={renderItem}
onDragEnd={onDragEnd} onReorder={onReorder}
> style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
<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>
</List> </List>
</> </>
) : ( ) : (
@@ -17,6 +17,8 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base' import { BaseSearchBox, type SearchState } from '../base'
@@ -174,6 +176,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') { if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay() onCheckDelay()
}} }}
> >
+6
View File
@@ -18,7 +18,9 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from '@/components/base' import { BaseSearchBox } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
import type { ProxySortType } from './use-filter-sort' import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state' import type { HeadState } from './use-head-state'
@@ -95,6 +97,10 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return
}
onCheckDelay() onCheckDelay()
}} }}
> >
+2 -7
View File
@@ -171,11 +171,7 @@ const DEFAULT_DNS_CONFIG = {
], ],
fallback: [], fallback: [],
'nameserver-policy': {}, 'nameserver-policy': {},
'proxy-server-nameserver': [ 'proxy-server-nameserver': [],
'https://doh.pub/dns-query',
'https://dns.alidns.com/dns-query',
'tls://223.5.5.5',
],
'direct-nameserver': [], 'direct-nameserver': [],
'direct-nameserver-follow-policy': false, 'direct-nameserver-follow-policy': false,
'fallback-filter': { 'fallback-filter': {
@@ -340,8 +336,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
listen: values.listen, listen: values.listen,
'enhanced-mode': values.enhancedMode, 'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange, 'fake-ip-range': values.fakeIpRange,
'fake-ip-range6': 'fake-ip-range6': values.fakeIpRange6,
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
'fake-ip-filter-mode': values.fakeIpFilterMode, 'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3, 'prefer-h3': values.preferH3,
'respect-rules': values.respectRules, 'respect-rules': values.respectRules,
@@ -31,6 +31,13 @@ export const StackModeSwitch = (props: Props) => {
> >
Mixed Mixed
</Button> </Button>
<Button
variant={value?.toLowerCase() === 'mips' ? 'contained' : 'outlined'}
onClick={() => onChange?.('mips')}
sx={{ textTransform: 'capitalize' }}
>
Mips
</Button>
</ButtonGroup> </ButtonGroup>
) )
} }
@@ -54,8 +54,9 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
} else { } else {
updateRef.current?.open() updateRef.current?.open()
} }
} catch (err: any) { } catch (err) {
showNotice.error(err) console.warn('update check failed:', err)
showNotice.warning(err)
} }
} }
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "تم تثبيت الخدمة بنجاح", "installSuccess": "تم تثبيت الخدمة بنجاح",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح" "uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Service erfolgreich installiert", "installSuccess": "Service erfolgreich installiert",
"sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.",
"uninstallSuccess": "Service erfolgreich deinstalliert" "uninstallSuccess": "Service erfolgreich deinstalliert"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Service Installed Successfully", "installSuccess": "Service Installed Successfully",
"sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.",
"uninstallSuccess": "Service Uninstalled Successfully" "uninstallSuccess": "Service Uninstalled Successfully"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Servicio instalado con éxito", "installSuccess": "Servicio instalado con éxito",
"sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.",
"uninstallSuccess": "Servicio desinstalado con éxito" "uninstallSuccess": "Servicio desinstalado con éxito"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "سرویس با موفقیت نصب شد", "installSuccess": "سرویس با موفقیت نصب شد",
"sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد" "uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Layanan Berhasil Diinstal", "installSuccess": "Layanan Berhasil Diinstal",
"sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.",
"uninstallSuccess": "Layanan Berhasil Dicopot" "uninstallSuccess": "Layanan Berhasil Dicopot"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "サービスのインストールに成功しました。", "installSuccess": "サービスのインストールに成功しました。",
"sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。",
"uninstallSuccess": "サービスのアンインストールに成功しました。" "uninstallSuccess": "サービスのアンインストールに成功しました。"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} 업데이트 성공", "updateSuccess": "{{name}} 업데이트 성공",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "서비스가 설치되었습니다", "installSuccess": "서비스가 설치되었습니다",
"sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.",
"uninstallSuccess": "서비스가 제거되었습니다" "uninstallSuccess": "서비스가 제거되었습니다"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} успешно обновлён", "updateSuccess": "{{name}} успешно обновлён",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Служба успешно установлена", "installSuccess": "Служба успешно установлена",
"sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.",
"uninstallSuccess": "Служба успешно удалена" "uninstallSuccess": "Служба успешно удалена"
}, },
"updater": { "updater": {
+4 -4
View File
@@ -17,18 +17,18 @@
"type": "Tip" "type": "Tip"
}, },
"order": { "order": {
"default": "Default", "default": "Varsayılan",
"uploadSpeed": "Yükleme Hızı", "uploadSpeed": "Yükleme Hızı",
"downloadSpeed": "İndirme Hızı" "downloadSpeed": "İndirme Hızı"
}, },
"actions": { "actions": {
"active": "Active", "active": "Etkin",
"closed": "Closed", "closed": "Kapatılan",
"closeConnection": "Bağlantıyı Kapat" "closeConnection": "Bağlantıyı Kapat"
}, },
"columnManager": { "columnManager": {
"title": "Sütunlar", "title": "Sütunlar",
"dragHandle": "Drag handle" "dragHandle": "Sürükleme tutamacı"
} }
} }
} }
+5 -5
View File
@@ -45,7 +45,7 @@
"fields": { "fields": {
"coreVersion": "Çekirdek Sürümü", "coreVersion": "Çekirdek Sürümü",
"systemProxyAddress": "Sistem Vekil Adresi", "systemProxyAddress": "Sistem Vekil Adresi",
"mixedPort": "Mixed Port", "mixedPort": "Karma Port",
"uptime": "Çalışma Süresi", "uptime": "Çalışma Süresi",
"rulesCount": "Kural Sayısı" "rulesCount": "Kural Sayısı"
} }
@@ -127,7 +127,7 @@
"download": "İndirme" "download": "İndirme"
}, },
"patterns": { "patterns": {
"minutes": "{{time}} Minutes" "minutes": "{{time}} Dakika"
}, },
"unknownTime": "Bilinmeyen zaman", "unknownTime": "Bilinmeyen zaman",
"chartStyles": { "chartStyles": {
@@ -146,9 +146,9 @@
"direct": "Doğrudan Mod" "direct": "Doğrudan Mod"
}, },
"descriptions": { "descriptions": {
"rule": "Automatically choose proxies according to the rule set.", "rule": "Vekilleri kural kümesine göre otomatik olarak seçer.",
"global": "Forward all network requests through the selected proxy.", "global": "Tüm ağ isteklerini seçili vekil üzerinden iletir.",
"direct": "Bypass the proxy and connect to the internet directly." "direct": "Vekili atlar ve internete doğrudan bağlanır."
} }
} }
} }
+14 -14
View File
@@ -12,23 +12,23 @@
"settings": "Ayarlar" "settings": "Ayarlar"
}, },
"menu": { "menu": {
"reorderMode": "Menu reorder mode", "reorderMode": "Menü sıralama modu",
"restoreDefaultOrder": "Restore default order", "restoreDefaultOrder": "Varsayılan sırayı geri yükle",
"unlock": "Unlock menu order", "unlock": "Menü sırasının kilidini aç",
"lock": "Lock menu order", "lock": "Menü sırasını kilitle",
"collapseNavBar": "Collapse navigation bar", "collapseNavBar": "Gezinme çubuğunu daralt",
"expandNavBar": "Expand navigation bar" "expandNavBar": "Gezinme çubuğunu genişlet"
} }
}, },
"serviceMigration": { "serviceMigration": {
"title": "Service update required", "title": "Hizmet güncellemesi gerekli",
"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": "Eski bir Clash Verge hizmet kurulumu algılandı. Güncel güvenli IPC protokolünü kullanmak için hizmeti yeniden kurun ya da bu oturumda Sidecar ile devam edin.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…", "checkingMessage": "Sistem hizmetinin durumu ve geçerli çalışma modu denetleniyor. Lütfen bekleyin…",
"unavailableMessage": "The required Clash Verge service is not available. Install or repair it, or continue with Sidecar for this session.", "unavailableMessage": "Gerekli Clash Verge hizmeti kullanılamıyor. Hizmeti kurun veya onarın ya da bu oturumda Sidecar ile devam edin.",
"reinstall": "Reinstall service", "reinstall": "Hizmeti yeniden kur",
"repair": "Repair service", "repair": "Hizmeti onar",
"continueSidecar": "Continue with Sidecar", "continueSidecar": "Sidecar ile devam et",
"success": "The Clash Verge service was updated successfully", "success": "Clash Verge hizmeti başarıyla güncellendi",
"errors": { "errors": {
"actionFailed": "Hizmet işlemi başarısız oldu", "actionFailed": "Hizmet işlemi başarısız oldu",
"stateRefreshFailed": "Hizmet durumu yenilenemedi", "stateRefreshFailed": "Hizmet durumu yenilenemedi",
+2 -2
View File
@@ -3,7 +3,7 @@
"title": "Günlükler" "title": "Günlükler"
}, },
"actions": { "actions": {
"showDescending": "Newest first", "showDescending": "Önce en yeni",
"showAscending": "Oldest first" "showAscending": "Önce en eski"
} }
} }
+16 -16
View File
@@ -27,7 +27,7 @@
}, },
"feedback": { "feedback": {
"errors": { "errors": {
"invalidUrl": "Invalid profile URL. Please enter a URL starting with http:// or https://", "invalidUrl": "Geçersiz profil URL'si. Lütfen http:// veya https:// ile başlayan bir URL girin",
"onlyYaml": "Yalnızca YAML Dosyaları Desteklenir", "onlyYaml": "Yalnızca YAML Dosyaları Desteklenir",
"createFailed": "Profil oluşturulamadı", "createFailed": "Profil oluşturulamadı",
"deleteFailed": "Profil silinemedi", "deleteFailed": "Profil silinemedi",
@@ -42,16 +42,16 @@
"notifications": { "notifications": {
"importRetry": "İçe aktarma başarısız oldu, Clash vekil ile yeniden deneniyor...", "importRetry": "İçe aktarma başarısız oldu, Clash vekil ile yeniden deneniyor...",
"importFail": "Clash vekil ile bile içe aktarma başarısız oldu", "importFail": "Clash vekil ile bile içe aktarma başarısız oldu",
"importNeedsRefresh": "Profile imported but may need manual refresh", "importNeedsRefresh": "Profil içe aktarıldı ancak elle yenilemek gerekebilir",
"importSuccess": "Profile imported successfully, please restart if not visible", "importSuccess": "Profil başarıyla içe aktarıldı, görünmüyorsa lütfen yeniden başlatın",
"profileSwitched": "Profil Değiştirildi", "profileSwitched": "Profil Değiştirildi",
"profileReactivated": "Profil Yeniden Etkinleştirildi", "profileReactivated": "Profil Yeniden Etkinleştirildi",
"switchBusy": "Profil değiştirme işlemi zaten devam ediyor", "switchBusy": "Profil değiştirme işlemi zaten devam ediyor",
"batchDeleted": "Seçili profiller başarıyla silindi" "batchDeleted": "Seçili profiller başarıyla silindi"
}, },
"notices": { "notices": {
"forceRefreshCompleted": "Force refresh completed", "forceRefreshCompleted": "Zorunlu yenileme tamamlandı",
"emergencyRefreshFailed": "Emergency refresh failed: {{message}}" "emergencyRefreshFailed": "Acil yenileme başarısız oldu: {{message}}"
}, },
"tooltips": { "tooltips": {
"forceRefreshStaleData": "Profil verileri kullanılamıyor; zorla yenilemek için tıklayın" "forceRefreshStaleData": "Profil verileri kullanılamıyor; zorla yenilemek için tıklayın"
@@ -71,7 +71,7 @@
"menu": { "menu": {
"home": "Ana Sayfa", "home": "Ana Sayfa",
"select": "Seç", "select": "Seç",
"shareQrCode": "Share QR Code", "shareQrCode": "QR Kodu Paylaş",
"editInfo": "Bilgileri Düzenle", "editInfo": "Bilgileri Düzenle",
"editFile": "Dosyayı Düzenle", "editFile": "Dosyayı Düzenle",
"editRules": "Kuralları Düzenle", "editRules": "Kuralları Düzenle",
@@ -81,22 +81,22 @@
"extendScript": "Betik Genişletme", "extendScript": "Betik Genişletme",
"openFile": "Dosyayı Aç", "openFile": "Dosyayı Aç",
"update": "Güncelle", "update": "Güncelle",
"updateViaProxy": "Update via proxy" "updateViaProxy": "Vekil üzerinden güncelle"
}, },
"more": { "more": {
"global": { "global": {
"merge": "Global Extend Config", "merge": "Küresel Yapılandırma Genişletme",
"script": "Global Extend Script" "script": "Küresel Betik Genişletme"
}, },
"chips": { "chips": {
"merge": "Merge", "merge": "Birleştirme",
"script": "Script" "script": "Betik"
} }
}, },
"profileItem": { "profileItem": {
"tooltips": { "tooltips": {
"showLast": "Click to show last update time", "showLast": "Son güncelleme zamanını göstermek için tıklayın",
"showNext": "Click to show next update" "showNext": "Sonraki güncellemeyi göstermek için tıklayın"
}, },
"status": { "status": {
"lastUpdateFailed": "Son güncelleme başarısız oldu", "lastUpdateFailed": "Son güncelleme başarısız oldu",
@@ -118,12 +118,12 @@
"description": "Açıklamalar", "description": "Açıklamalar",
"subscriptionUrl": "Abonelik URL'si", "subscriptionUrl": "Abonelik URL'si",
"userAgent": "Kullanıcı Aracısı", "userAgent": "Kullanıcı Aracısı",
"httpTimeout": "HTTP Request Timeout", "httpTimeout": "HTTP İsteği Zaman Aşımı",
"updateInterval": "Güncelleme Aralığı", "updateInterval": "Güncelleme Aralığı",
"useSystemProxy": "Sistem Vekil'ini Kullan", "useSystemProxy": "Sistem Vekil'ini Kullan",
"useClashProxy": "Clash Vekil'ini Kullan", "useClashProxy": "Clash Vekil'ini Kullan",
"acceptInvalidCerts": "Geçersiz Sertifikalara İzin Ver (Tehlikeli)", "acceptInvalidCerts": "Geçersiz Sertifikalara İzin Ver (Tehlikeli)",
"allowAutoUpdate": "Allow Auto Update" "allowAutoUpdate": "Otomatik Güncellemeye İzin Ver"
}, },
"types": { "types": {
"remote": "Uzak", "remote": "Uzak",
@@ -206,7 +206,7 @@
"title": "Betik Konsolu" "title": "Betik Konsolu"
}, },
"qrViewer": { "qrViewer": {
"title": "Subscription QR Code" "title": "Abonelik QR Kodu"
} }
} }
} }
+27 -24
View File
@@ -1,16 +1,16 @@
{ {
"page": { "page": {
"modes": { "modes": {
"rule": "Rule", "rule": "Kural",
"global": "Global", "global": "Küresel",
"direct": "Direct" "direct": "Doğrudan"
}, },
"actions": { "actions": {
"toggleChain": "Zincir Proxy", "toggleChain": "Zincir Proxy",
"connect": "Connect", "connect": "Bağlan",
"disconnect": "Disconnect", "disconnect": "Bağlantıyı Kes",
"connecting": "Connecting...", "connecting": "Bağlanıyor...",
"clearChainConfig": "Delete Chain Config" "clearChainConfig": "Zincir Yapılandırmasını Sil"
}, },
"provider": { "provider": {
"title": "Vekil Sağlayıcısı", "title": "Vekil Sağlayıcısı",
@@ -20,8 +20,8 @@
} }
}, },
"rules": { "rules": {
"title": "Proxy Rules", "title": "Vekil Kuralları",
"select": "Select Rules" "select": "Kural Seç"
}, },
"labels": { "labels": {
"proxyCount": "Vekil Sayısı", "proxyCount": "Vekil Sayısı",
@@ -68,15 +68,15 @@
} }
}, },
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Zincir Vekil Yapılandırması",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Zincir vekil en az 2 düğüm gerektirir",
"minimumNodesHint": "Chain proxy requires at least 2 nodes. Please add one more node.", "minimumNodesHint": "Zincir vekil en az 2 düğüm gerektirir. Lütfen bir düğüm daha ekleyin.",
"connectFailed": "Failed to connect to proxy chain", "connectFailed": "Vekil zincirine bağlanılamadı",
"disconnectFailed": "Failed to disconnect from proxy chain", "disconnectFailed": "Vekil zinciriyle bağlantı kesilemedi",
"duplicateNode": "Proxy node already exists in chain", "duplicateNode": "Vekil düğümü zincirde zaten var",
"entryNode": "Giriş", "entryNode": "Giriş",
"exitNode": "Çıkış" "exitNode": "Çıkış"
}, },
@@ -85,17 +85,20 @@
}, },
"title": { "title": {
"default": "Vekil Grupları", "default": "Vekil Grupları",
"chainMode": "Proxy Chain Mode" "chainMode": "Vekil Zinciri Modu"
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL'si http:// veya https:// ile başlamalıdır"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} başarıyla güncellendi",
"updateFailed": "Failed to update {{name}}: {{message}}", "updateFailed": "{{name}} güncellenemedi: {{message}}",
"genericError": "Update failed: {{message}}", "genericError": "Güncelleme başarısız oldu: {{message}}",
"none": "No providers available to update", "none": "Güncellenecek sağlayıcı yok",
"allUpdated": "All providers updated successfully" "allUpdated": "Tüm sağlayıcılar başarıyla güncellendi"
} }
} }
}, },
+5 -5
View File
@@ -13,11 +13,11 @@
"feedback": { "feedback": {
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} başarıyla güncellendi",
"updateFailed": "Failed to update {{name}}: {{message}}", "updateFailed": "{{name}} güncellenemedi: {{message}}",
"genericError": "Update failed: {{message}}", "genericError": "Güncelleme başarısız oldu: {{message}}",
"none": "No providers available to update", "none": "Güncellenecek sağlayıcı yok",
"allUpdated": "All providers updated successfully" "allUpdated": "Tüm sağlayıcılar başarıyla güncellendi"
} }
} }
}, },
+85 -84
View File
@@ -3,7 +3,7 @@
"actions": { "actions": {
"manual": "Kılavuz", "manual": "Kılavuz",
"telegram": "Telegram Kanalı", "telegram": "Telegram Kanalı",
"github": "Github Repo" "github": "GitHub Deposu"
}, },
"actionsGroupLabel": "Ayar bağlantıları", "actionsGroupLabel": "Ayar bağlantıları",
"title": "Ayarlar" "title": "Ayarlar"
@@ -24,8 +24,8 @@
}, },
"notifications": { "notifications": {
"tunMode": { "tunMode": {
"autoDisabled": "TUN Mode automatically disabled due to service unavailable", "autoDisabled": "Hizmet kullanılamadığı için TUN Modu otomatik olarak devre dışı bırakıldı",
"autoDisableFailed": "Failed to disable TUN Mode automatically" "autoDisableFailed": "TUN Modu otomatik olarak devre dışı bırakılamadı"
} }
} }
}, },
@@ -33,11 +33,11 @@
"tooltips": { "tooltips": {
"systemProxy": "İşletim sisteminin vekil ayarlarını değiştirmek için etkinleştirin. Etkinleştirme başarısız olursa, işletim sisteminin proxy ayarlarını manuel olarak değiştirin", "systemProxy": "İşletim sisteminin vekil ayarlarını değiştirmek için etkinleştirin. Etkinleştirme başarısız olursa, işletim sisteminin proxy ayarlarını manuel olarak değiştirin",
"tunMode": "Tun (Sanal Ağ Kartı) modu: Tüm sistem trafiğini yakalar, etkinleştirildiğinde sistem vekil'ini etkinleştirmeye gerek yoktur.", "tunMode": "Tun (Sanal Ağ Kartı) modu: Tüm sistem trafiğini yakalar, etkinleştirildiğinde sistem vekil'ini etkinleştirmeye gerek yoktur.",
"tunUnavailable": "TUN requires Service Mode or Admin Mode" "tunUnavailable": "TUN, Hizmet Modu veya Yönetici Modu gerektirir"
}, },
"actions": { "actions": {
"installService": "Hizmeti Kur", "installService": "Hizmeti Kur",
"uninstallService": "Uninstall Service", "uninstallService": "Hizmeti Kaldır",
"switchToServiceMode": "Hizmet moduna geç" "switchToServiceMode": "Hizmet moduna geç"
}, },
"fields": { "fields": {
@@ -92,9 +92,9 @@
} }
}, },
"appearance": { "appearance": {
"light": "Light", "light": "Açık",
"dark": "Dark", "dark": "Koyu",
"system": "System" "system": "Sistem"
}, },
"clash": { "clash": {
"title": "Clash Ayarı", "title": "Clash Ayarı",
@@ -144,11 +144,11 @@
}, },
"options": { "options": {
"logLevel": { "logLevel": {
"debug": "Debug", "debug": "Hata Ayıklama",
"info": "Info", "info": "Bilgi",
"warning": "Warn", "warning": "Uyarı",
"error": "Error", "error": "Hata",
"silent": "Silent" "silent": "Sessiz"
} }
} }
} }
@@ -187,11 +187,11 @@
"liteMode": "GUI'yi kapatın ve yalnızca çekirdeği çalışır durumda tutun" "liteMode": "GUI'yi kapatın ve yalnızca çekirdeği çalışır durumda tutun"
}, },
"actions": { "actions": {
"copyVersion": "Copy Version" "copyVersion": "Sürümü Kopyala"
}, },
"notifications": { "notifications": {
"latestVersion": "Şu Anda En Son Sürümdesiniz", "latestVersion": "Şu Anda En Son Sürümdesiniz",
"versionCopied": "Version copied to clipboard" "versionCopied": "Sürüm panoya kopyalandı"
}, },
"fields": { "fields": {
"backupSetting": "Yedekleme Ayarı", "backupSetting": "Yedekleme Ayarı",
@@ -222,27 +222,27 @@
"cssInjection": "CSS Enjeksiyonu" "cssInjection": "CSS Enjeksiyonu"
}, },
"actions": { "actions": {
"editCss": "Edit CSS" "editCss": "CSS'i Düzenle"
}, },
"dialogs": { "dialogs": {
"editCssTitle": "Edit CSS" "editCssTitle": "CSS'i Düzenle"
} }
}, },
"layout": { "layout": {
"title": "Düzen Ayarı", "title": "Düzen Ayarı",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "preferSystemTitlebar": "Sistem Başlık Çubuğunu Tercih Et",
"trafficGraph": "Trafik Grafiği", "trafficGraph": "Trafik Grafiği",
"memoryUsage": "Çekirdek Kullanımı", "memoryUsage": "Çekirdek Kullanımı",
"proxyGroupIcon": "Vekil Grup Simgesi", "proxyGroupIcon": "Vekil Grup Simgesi",
"toastPosition": "Toast konumu", "toastPosition": "Toast konumu",
"hoverNavigator": "Hover Jump Navigator", "hoverNavigator": "Üzerine Gelince Atlama Gezgini",
"hoverNavigatorDelay": "Hover Jump Navigator Delay", "hoverNavigatorDelay": "Üzerine Gelince Atlama Gecikmesi",
"navIcon": "Gezinme Simgesi", "navIcon": "Gezinme Simgesi",
"collapseNavBar": "Gezinme çubuğunu daralt", "collapseNavBar": "Gezinme çubuğunu daralt",
"trayIcon": "Tepsi Simgesi", "trayIcon": "Tepsi Simgesi",
"proxyGroupsDisplayMode": "Proxy Groups Display Mode", "proxyGroupsDisplayMode": "Vekil Grupları Görüntüleme Modu",
"showOutboundModesInline": "Show Outbound Modes Inline", "showOutboundModesInline": "Çıkış Modlarını Satır İçinde Göster",
"commonTrayIcon": "Genel Tepsi Simgesi", "commonTrayIcon": "Genel Tepsi Simgesi",
"systemProxyTrayIcon": "Sistem Vekil Tepsi Simgesi", "systemProxyTrayIcon": "Sistem Vekil Tepsi Simgesi",
"tunTrayIcon": "Tun Tepsi Simgesi", "tunTrayIcon": "Tun Tepsi Simgesi",
@@ -251,8 +251,8 @@
"pauseRenderTrafficStatsOnBlur": "Odak kaybolduğunda trafik istatistiklerinin işlenmesini duraklat" "pauseRenderTrafficStatsOnBlur": "Odak kaybolduğunda trafik istatistiklerinin işlenmesini duraklat"
}, },
"tooltips": { "tooltips": {
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Alfabe harflerinin üzerine gelindiğinde ilgili vekil grubuna otomatik olarak kaydırır",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Üzerine gelindiğinde otomatik kaydırmadan önceki gecikme, milisaniye cinsinden"
}, },
"options": { "options": {
"icon": { "icon": {
@@ -267,9 +267,9 @@
"bottomRight": "Sağ alt" "bottomRight": "Sağ alt"
}, },
"proxyGroupsDisplayMode": { "proxyGroupsDisplayMode": {
"default": "Default", "default": "Varsayılan",
"inline": "Inline", "inline": "Satır İçi",
"disable": "Disable" "disable": "Devre Dışı"
} }
} }
} }
@@ -289,9 +289,9 @@
"random": "Rastgele Port" "random": "Rastgele Port"
}, },
"messages": { "messages": {
"portInUse": "Port {{port}} is already in use", "portInUse": "{{port}} portu zaten kullanımda",
"saved": "Port settings saved", "saved": "Port ayarları kaydedildi",
"saveFailed": "Failed to save port settings", "saveFailed": "Port ayarları kaydedilemedi",
"automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.", "automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.",
"automaticFallbackFailed": "Karma proxy portu otomatik olarak değiştirilemedi: {{error}}", "automaticFallbackFailed": "Karma proxy portu otomatik olarak değiştirilemedi: {{error}}",
"portTooLow": "Bağlantı noktası 1000 veya daha yüksek olmalıdır", "portTooLow": "Bağlantı noktası 1000 veya daha yüksek olmalıdır",
@@ -325,24 +325,24 @@
"backup": { "backup": {
"title": "Yedekleme Ayarı", "title": "Yedekleme Ayarı",
"tabs": { "tabs": {
"local": "Local backup", "local": "Yerel yedek",
"webdav": "WebDAV backup" "webdav": "WebDAV yedeği"
}, },
"actions": { "actions": {
"selectTarget": "Select backup target", "selectTarget": "Yedekleme hedefini seçin",
"backup": "Yedekle", "backup": "Yedekle",
"export": "Export", "export": "Dışa Aktar",
"exportBackup": "Export Backup", "exportBackup": "Yedeği Dışa Aktar",
"importBackup": "Import Backup", "importBackup": "Yedeği İçe Aktar",
"deleteBackup": "Yedeği Sil", "deleteBackup": "Yedeği Sil",
"restore": "Geri Yükle", "restore": "Geri Yükle",
"restoreBackup": "Yedeği Geri Yükle", "restoreBackup": "Yedeği Geri Yükle",
"viewHistory": "View history" "viewHistory": "Geçmişi görüntüle"
}, },
"fields": { "fields": {
"webdavUrl": "WebDAV Sunucu URL'si", "webdavUrl": "WebDAV Sunucu URL'si",
"username": "Kullanıcı Adı", "username": "Kullanıcı Adı",
"info": "Backups are stored locally in the application data directory. Use the list below to restore or delete backups." "info": "Yedekler yerel olarak uygulama veri dizininde saklanır. Yedekleri geri yüklemek veya silmek için aşağıdaki listeyi kullanın."
}, },
"messages": { "messages": {
"webdavUrlRequired": "WebDAV URL'si boş olamaz", "webdavUrlRequired": "WebDAV URL'si boş olamaz",
@@ -353,60 +353,60 @@
"webdavConfigSaveFailed": "WebDAV yapılandırması kaydedilemedi: {{error}}", "webdavConfigSaveFailed": "WebDAV yapılandırması kaydedilemedi: {{error}}",
"backupCreated": "Yedek başarıyla oluşturuldu", "backupCreated": "Yedek başarıyla oluşturuldu",
"backupFailed": "Yedekleme başarısız oldu: {{error}}", "backupFailed": "Yedekleme başarısız oldu: {{error}}",
"localBackupCreated": "Local backup created successfully", "localBackupCreated": "Yerel yedek başarıyla oluşturuldu",
"localBackupFailed": "Local backup failed", "localBackupFailed": "Yerel yedekleme başarısız oldu",
"restoreSuccess": "Geri Yükleme Başarılı, Uygulama 1 saniye içinde yeniden başlatılacak", "restoreSuccess": "Geri Yükleme Başarılı, Uygulama 1 saniye içinde yeniden başlatılacak",
"localBackupExported": "Local backup exported successfully", "localBackupExported": "Yerel yedek başarıyla dışa aktarıldı",
"localBackupExportFailed": "Failed to export local backup", "localBackupExportFailed": "Yerel yedek dışa aktarılamadı",
"localBackupImported": "Local backup imported successfully", "localBackupImported": "Yerel yedek başarıyla içe aktarıldı",
"localBackupImportFailed": "Failed to import local backup: {{error}}", "localBackupImportFailed": "Yerel yedek içe aktarılamadı: {{error}}",
"webdavRefreshSuccess": "WebDAV refresh succeeded", "webdavRefreshSuccess": "WebDAV yenilemesi başarılı",
"webdavRefreshFailed": "WebDAV refresh failed: {{error}}", "webdavRefreshFailed": "WebDAV yenilemesi başarısız oldu: {{error}}",
"confirmDelete": "Bu yedek dosyasını silmeyi onaylıyor musunuz?", "confirmDelete": "Bu yedek dosyasını silmeyi onaylıyor musunuz?",
"confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?" "confirmRestore": "Bu yedek dosyasını geri yüklemeyi onaylıyor musunuz?"
}, },
"auto": { "auto": {
"title": "Automatic backup", "title": "Otomatik yedekleme",
"scheduleLabel": "Enable scheduled backup", "scheduleLabel": "Zamanlanmış yedeklemeyi etkinleştir",
"scheduleHelper": "Create local backups in the background at the configured interval.", "scheduleHelper": "Yapılandırılan aralıkta arka planda yerel yedekler oluşturur.",
"intervalLabel": "Backup frequency", "intervalLabel": "Yedekleme sıklığı",
"changeLabel": "Backup on critical changes", "changeLabel": "Kritik değişikliklerde yedekle",
"changeHelper": "Automatically backup when Global Extend Config/Script changes.", "changeHelper": "Küresel Yapılandırma/Betik Genişletme değiştiğinde otomatik olarak yedekler.",
"options": { "options": {
"hours": "Every {{n}} hours", "hours": "{{n}} saatte bir",
"days": "Every {{n}} days" "days": "{{n}} günde bir"
} }
}, },
"manual": { "manual": {
"title": "Manual backup", "title": "Elle yedekleme",
"local": "Creates a snapshot on this device, stored under the app data directory.", "local": "Bu cihazda, uygulama veri dizini altında saklanan bir anlık görüntü oluşturur.",
"webdav": "Upload a snapshot to your WebDAV server once credentials are set.", "webdav": "Kimlik bilgileri ayarlandıktan sonra WebDAV sunucunuza bir anlık görüntü yükler.",
"configureWebdav": "Configure WebDAV" "configureWebdav": "WebDAV'ı Yapılandır"
}, },
"history": { "history": {
"title": "Backup history", "title": "Yedekleme geçmişi",
"summary": "{{count}} backups • latest {{recent}}", "summary": "{{count}} yedek • en son {{recent}}",
"empty": "No backups available", "empty": "Kullanılabilir yedek yok",
"unknownPlatform": "unknown", "unknownPlatform": "bilinmiyor",
"unknownTime": "Unknown time" "unknownTime": "Bilinmeyen zaman"
}, },
"webdav": { "webdav": {
"title": "WebDAV settings" "title": "WebDAV ayarları"
}, },
"table": { "table": {
"filename": "Dosya Adı", "filename": "Dosya Adı",
"backupTime": "Yedekleme Zamanı", "backupTime": "Yedekleme Zamanı",
"actions": "İşlemler", "actions": "İşlemler",
"noBackups": "Kullanılabilir yedek yok", "noBackups": "Kullanılabilir yedek yok",
"rowsPerPage": "Rows per page" "rowsPerPage": "Sayfa başına satır"
} }
}, },
"misc": { "misc": {
"title": "Çeşitli", "title": "Çeşitli",
"fields": { "fields": {
"appLogLevel": "Uygulama Günlük Seviyesi", "appLogLevel": "Uygulama Günlük Seviyesi",
"appLogMaxSize": "App Log Max Size", "appLogMaxSize": "Uygulama Günlüğü En Büyük Boyutu",
"appLogMaxCount": "App Log Max Count", "appLogMaxCount": "Uygulama Günlüğü En Fazla Dosya Sayısı",
"autoCloseConnections": "Bağlantıları Otomatik Kapat", "autoCloseConnections": "Bağlantıları Otomatik Kapat",
"autoCheckUpdate": "Otomatik Güncelleme Kontrolü", "autoCheckUpdate": "Otomatik Güncelleme Kontrolü",
"enableBuiltinEnhanced": "Yerleşik Geliştirilmiş Modu Etkinleştir", "enableBuiltinEnhanced": "Yerleşik Geliştirilmiş Modu Etkinleştir",
@@ -434,7 +434,7 @@
} }
}, },
"update": { "update": {
"title": "New Version v{{version}}", "title": "Yeni Sürüm v{{version}}",
"actions": { "actions": {
"goToRelease": "Sürüm Sayfasına Git", "goToRelease": "Sürüm Sayfasına Git",
"update": "Güncelle" "update": "Güncelle"
@@ -486,18 +486,18 @@
"title": "Tun Modu", "title": "Tun Modu",
"fields": { "fields": {
"stack": "Tun Yığını", "stack": "Tun Yığını",
"device": "Device Name", "device": "Aygıt Adı",
"autoRoute": "Otomatik Yönlendirme", "autoRoute": "Otomatik Yönlendirme",
"routeExcludeAddress": "Yönlendirme Hariç Adresler", "routeExcludeAddress": "Yönlendirme Hariç Adresler",
"strictRoute": "Katı Yönlendirme", "strictRoute": "Katı Yönlendirme",
"autoDetectInterface": "Arayüzü Otomatik Algıla", "autoDetectInterface": "Arayüzü Otomatik Algıla",
"dnsHijack": "DNS Ele Geçirme", "dnsHijack": "DNS Ele Geçirme",
"mtu": "Maksimum İletim Birimi", "mtu": "Maksimum İletim Birimi",
"autoRedirect": "Auto Redirect" "autoRedirect": "Otomatik Yönlendirme"
}, },
"tooltips": { "tooltips": {
"dnsHijack": "Please use , to separate multiple DNS servers", "dnsHijack": "Birden fazla DNS sunucusunu ayırmak için lütfen , kullanın",
"autoRedirect": "Automatically configures nftables/iptables TCP redirects" "autoRedirect": "nftables/iptables TCP yönlendirmelerini otomatik olarak yapılandırır"
}, },
"messages": { "messages": {
"applied": "Ayarlar Uygulandı", "applied": "Ayarlar Uygulandı",
@@ -511,11 +511,11 @@
"warning": "Bu ayarlarla ilgili bilginiz yoksa, lütfen bunları değiştirmeyin ve DNS Üzerine Yazma'yı devre dışı tutun" "warning": "Bu ayarlarla ilgili bilginiz yoksa, lütfen bunları değiştirmeyin ve DNS Üzerine Yazma'yı devre dışı tutun"
}, },
"protection": { "protection": {
"title": "Enable DNS Overwrite?", "title": "DNS Üzerine Yazma etkinleştirilsin mi?",
"message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", "message": "Bu abonelik özel DNS ayarları içeriyor. DNS Üzerine Yazma bunların yerine geçebilir; bu da düğümlerin bağlanmasını veya bazı web sitelerinin yüklenmesini engelleyebilir. Yine de etkinleştirilsin mi?",
"enableAnyway": "Enable anyway", "enableAnyway": "Yine de etkinleştir",
"keepDisabled": "Keep disabled", "keepDisabled": "Devre dışı bırak",
"autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." "autoDisabled": "Geçerli abonelikteki özel DNS ayarlarını korumak için DNS Üzerine Yazma devre dışı bırakıldı."
}, },
"sections": { "sections": {
"general": "DNS Ayarları", "general": "DNS Ayarları",
@@ -535,7 +535,7 @@
}, },
"preferH3": { "preferH3": {
"label": "H3'ü Tercih Et", "label": "H3'ü Tercih Et",
"description": "DNS DOH uses HTTP/3" "description": "DNS DOH, HTTP/3 kullanır"
}, },
"respectRules": { "respectRules": {
"label": "Kurallara Uy", "label": "Kurallara Uy",
@@ -601,11 +601,11 @@
}, },
"messages": { "messages": {
"saved": "DNS ayarları kaydedildi", "saved": "DNS ayarları kaydedildi",
"configError": "DNS configuration error:" "configError": "DNS yapılandırma hatası:"
}, },
"errors": { "errors": {
"invalid": "Invalid configuration", "invalid": "Geçersiz yapılandırma",
"invalidYaml": "Invalid YAML format" "invalidYaml": "Geçersiz YAML biçimi"
} }
}, },
"webUI": { "webUI": {
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Hizmet Başarıyla Kuruldu", "installSuccess": "Hizmet Başarıyla Kuruldu",
"sidecarFallback": "Hizmet, yol izinleri nedeniyle çekirdeği başlatamadı. Çalışmaya devam etmek için yönetici moduna geçildi.",
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı" "uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
}, },
"updater": { "updater": {
@@ -693,12 +694,12 @@
}, },
"statuses": { "statuses": {
"clash": { "clash": {
"stopping": "Stopping Core...", "stopping": "Çekirdek Durduruluyor...",
"restarting": "Restarting Core..." "restarting": "Çekirdek Yeniden Başlatılıyor..."
}, },
"clashService": { "clashService": {
"installing": "Hizmet Kuruluyor...", "installing": "Hizmet Kuruluyor...",
"uninstalling": "Uninstalling Service..." "uninstalling": "Hizmet Kaldırılıyor..."
} }
} }
} }
+17 -17
View File
@@ -12,23 +12,23 @@
"restart": "Yeniden Başlat", "restart": "Yeniden Başlat",
"resetToDefault": "Varsayılana Sıfırla", "resetToDefault": "Varsayılana Sıfırla",
"refresh": "Yenile", "refresh": "Yenile",
"retry": "Retry", "retry": "Yeniden Dene",
"refreshPage": "Refresh Page", "refreshPage": "Sayfayı Yenile",
"showDetails": "Show Details", "showDetails": "Ayrıntıları Göster",
"hideDetails": "Hide Details", "hideDetails": "Ayrıntıları Gizle",
"listView": "Liste Görünümü", "listView": "Liste Görünümü",
"tableView": "Tablo Görünümü", "tableView": "Tablo Görünümü",
"pause": "Duraklat", "pause": "Duraklat",
"resume": "Sürdür", "resume": "Sürdür",
"closeAll": "Tümünü Kapat", "closeAll": "Tümünü Kapat",
"clear": "Temizle", "clear": "Temizle",
"previous": "Previous", "previous": "Önceki",
"next": "Next", "next": "Sonraki",
"check": "Denetle" "check": "Denetle"
}, },
"labels": { "labels": {
"updateAt": "Güncelleme Zamanı", "updateAt": "Güncelleme Zamanı",
"timeout": "Timeout", "timeout": "Zaman Aşımı",
"icon": "Simge", "icon": "Simge",
"name": "İsim", "name": "İsim",
"readOnly": "Salt Okunur", "readOnly": "Salt Okunur",
@@ -37,14 +37,14 @@
"usedTotal": "Kullanılan / Toplam", "usedTotal": "Kullanılan / Toplam",
"from": "Kaynak", "from": "Kaynak",
"password": "Şifre", "password": "Şifre",
"retryAttempts": "Retry attempts", "retryAttempts": "Yeniden deneme sayısı",
"downloaded": "İndirilen", "downloaded": "İndirilen",
"uploaded": "Yüklenen" "uploaded": "Yüklenen"
}, },
"statuses": { "statuses": {
"enabled": "Etkin", "enabled": "Etkin",
"disabled": "Devre Dışı", "disabled": "Devre Dışı",
"saving": "Saving...", "saving": "Kaydediliyor...",
"empty": "Boş", "empty": "Boş",
"loading": "Yükleniyor..." "loading": "Yükleniyor..."
}, },
@@ -54,7 +54,7 @@
"minutes": "dakika", "minutes": "dakika",
"hours": "saat", "hours": "saat",
"kilobytes": "KB", "kilobytes": "KB",
"files": "Files" "files": "Dosya"
}, },
"placeholders": { "placeholders": {
"resetInput": "Giriş alanını temizle", "resetInput": "Giriş alanını temizle",
@@ -64,7 +64,7 @@
"useRegex": "Düzenli İfade Kullan" "useRegex": "Düzenli İfade Kullan"
}, },
"validation": { "validation": {
"invalidRegex": "Invalid regular expression" "invalidRegex": "Geçersiz düzenli ifade"
}, },
"window": { "window": {
"maximize": "Büyüt", "maximize": "Büyüt",
@@ -84,8 +84,8 @@
"componentStack": "Bileşen yığını", "componentStack": "Bileşen yığını",
"trafficUnavailable": "Trafik verileri kullanılamıyor", "trafficUnavailable": "Trafik verileri kullanılamıyor",
"operationFailed": "İşlem başarısız oldu", "operationFailed": "İşlem başarısız oldu",
"trafficStats": "Traffic Statistics Error", "trafficStats": "Trafik İstatistikleri Hatası",
"trafficStatsDescription": "The traffic statistics component encountered an error and has been disabled to prevent crashes." "trafficStatsDescription": "Trafik istatistikleri bileşeni bir hatayla karşılaştı ve çökmeleri önlemek için devre dışı bırakıldı."
}, },
"notices": { "notices": {
"raw": "{{message}}", "raw": "{{message}}",
@@ -95,12 +95,12 @@
"importSuccess": "Profil Başarıyla İçe Aktarıldı", "importSuccess": "Profil Başarıyla İçe Aktarıldı",
"importSubscriptionSuccess": "Abonelik içe aktarımı başarılı", "importSubscriptionSuccess": "Abonelik içe aktarımı başarılı",
"importWithClashProxy": "Profil Clash vekil ile içe aktarıldı", "importWithClashProxy": "Profil Clash vekil ile içe aktarıldı",
"updateAvailable": "Update Available", "updateAvailable": "Güncelleme Mevcut",
"saved": "Saved successfully", "saved": "Başarıyla kaydedildi",
"common": { "common": {
"copySuccess": "Kopyalama Başarılı", "copySuccess": "Kopyalama Başarılı",
"saveSuccess": "Configuration saved successfully", "saveSuccess": "Yapılandırma başarıyla kaydedildi",
"saveFailed": "Failed to save configuration", "saveFailed": "Yapılandırma kaydedilemedi",
"refreshFailed": "Yenileme başarısız" "refreshFailed": "Yenileme başarısız"
} }
}, },
+2 -2
View File
@@ -49,10 +49,10 @@
"actions": { "actions": {
"testing": "Test Ediliyor..." "testing": "Test Ediliyor..."
}, },
"empty": "No unlock test items", "empty": "Kilit açma testi öğesi yok",
"messages": { "messages": {
"detectionFailedWithName": "{{name}} için tespit başarısız oldu", "detectionFailedWithName": "{{name}} için tespit başarısız oldu",
"detectionTimeout": "Detection timeout or failed" "detectionTimeout": "Algılama zaman aşımına uğradı veya başarısız oldu"
}, },
"title": "Kilit Açma Testi" "title": "Kilit Açma Testi"
} }
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "Сервис уңышлы урнаштырылды", "installSuccess": "Сервис уңышлы урнаштырылды",
"sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.",
"uninstallSuccess": "Сервис уңышлы салдырылды" "uninstallSuccess": "Сервис уңышлы салдырылды"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} 更新成功", "updateSuccess": "{{name}} 更新成功",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "已成功安装服务", "installSuccess": "已成功安装服务",
"sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。",
"uninstallSuccess": "已成功卸载服务" "uninstallSuccess": "已成功卸载服务"
}, },
"updater": { "updater": {
+3
View File
@@ -89,6 +89,9 @@
} }
}, },
"feedback": { "feedback": {
"warnings": {
"invalidTestUrl": "Test URL must start with http:// or https://"
},
"notifications": { "notifications": {
"provider": { "provider": {
"updateSuccess": "{{name}} updated successfully", "updateSuccess": "{{name}} updated successfully",
+1
View File
@@ -683,6 +683,7 @@
}, },
"clashService": { "clashService": {
"installSuccess": "已成功安裝服務", "installSuccess": "已成功安裝服務",
"sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。",
"uninstallSuccess": "已成功解除安裝服務" "uninstallSuccess": "已成功解除安裝服務"
}, },
"updater": { "updater": {
@@ -13,7 +13,10 @@ vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() })) vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] })) vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() })) vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
vi.mock('@/services/cmds', () => ({ takeDnsOverrideNotice: vi.fn() })) vi.mock('@/services/cmds', () => ({
takeDnsOverrideNotice: vi.fn(),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/services/notice-service', () => ({ vi.mock('@/services/notice-service', () => ({
showNotice: { info: vi.fn() }, showNotice: { info: vi.fn() },
})) }))
@@ -64,6 +64,7 @@ export const useLayoutEvents = (
() => { () => {
revalidateKeys(['getRuntimeState', 'getVergeConfig']) revalidateKeys(['getRuntimeState', 'getVergeConfig'])
handleNotice(['dns_override::auto_disabled', '']) handleNotice(['dns_override::auto_disabled', ''])
handleNotice(['service_core::sidecar_fallback', ''])
}, },
) )
}, [handleNotice]) }, [handleNotice])
@@ -1,4 +1,7 @@
import { takeDnsOverrideNotice } from '@/services/cmds' import {
takeDnsOverrideNotice,
takeServiceFallbackNotice,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
type NavigateFunction = (path: string, options?: any) => void type NavigateFunction = (path: string, options?: any) => void
@@ -25,6 +28,22 @@ export const handleNoticeMessage = (
showNotice.error(msg) showNotice.error(msg)
}, },
'set_config::error': () => showNotice.error(msg), 'set_config::error': () => showNotice.error(msg),
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((pending) => {
if (pending) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
}
})
.catch((error) => {
console.error(
'Failed to read the pending service fallback notice',
error,
)
})
},
'dns_override::auto_disabled': () => { 'dns_override::auto_disabled': () => {
void takeDnsOverrideNotice() void takeDnsOverrideNotice()
.then((pending) => { .then((pending) => {
+2 -1
View File
@@ -59,6 +59,7 @@ import {
useThemeMode, useThemeMode,
} from '@/services/states' } from '@/services/states'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
// 与 src-tauri/src/main.rs 的 worker_limit 上限(8)保持一致,避免前后端更新风暴不对齐 // 与 src-tauri/src/main.rs 的 worker_limit 上限(8)保持一致,避免前后端更新风暴不对齐
const PROFILE_UPDATE_WORKER_LIMIT = 8 const PROFILE_UPDATE_WORKER_LIMIT = 8
@@ -214,7 +215,7 @@ const ProfilePage = () => {
const onImport = async () => { const onImport = async () => {
if (!url) return if (!url) return
if (!/^https?:\/\//i.test(url)) { if (!isValidUrl(url)) {
showNotice.error('profiles.page.feedback.errors.invalidUrl') showNotice.error('profiles.page.feedback.errors.invalidUrl')
return return
} }
+4
View File
@@ -182,6 +182,10 @@ export async function takeDnsOverrideNotice() {
return invoke<boolean>('take_dns_override_notice') return invoke<boolean>('take_dns_override_notice')
} }
export async function takeServiceFallbackNotice() {
return invoke<boolean>('take_service_fallback_notice')
}
export async function getSystemProxy() { export async function getSystemProxy() {
return invoke<{ return invoke<{
enable: boolean enable: boolean
+6
View File
@@ -12,6 +12,7 @@ import {
} from '@/types/proxy-view' } from '@/types/proxy-view'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay'
import { isValidUrl } from '@/utils/network'
export type DelaySnapshot = { export type DelaySnapshot = {
of: (member: ResolvedProxyMember) => number of: (member: ResolvedProxyMember) => number
@@ -152,6 +153,11 @@ class DelayManager {
} }
setUrl(group: string, url: string) { setUrl(group: string, url: string) {
if (!isValidUrl(url)) {
debugLog(`[DelayManager] 拒绝无效测试URL,组: ${group}, URL: ${url}`)
this.urlMap.delete(group)
return
}
debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`)
this.urlMap.set(group, url) this.urlMap.set(group, url)
} }
+5 -1
View File
@@ -4,7 +4,7 @@ import { ReactNode, isValidElement } from 'react'
import type { FailedOperation } from '@/services/cmds' import type { FailedOperation } from '@/services/cmds'
import type { TranslationKey } from '@/types/generated/i18n-keys' import type { TranslationKey } from '@/types/generated/i18n-keys'
type NoticeType = 'success' | 'error' | 'info' type NoticeType = 'success' | 'error' | 'info' | 'warning'
interface NoticeTranslationDescriptor { interface NoticeTranslationDescriptor {
key: string key: string
@@ -47,6 +47,7 @@ type ShowNotice = ((
success: NoticeShortcut success: NoticeShortcut
error: NoticeShortcut error: NoticeShortcut
info: NoticeShortcut info: NoticeShortcut
warning: NoticeShortcut
} }
type NoticeSubscriber = () => void type NoticeSubscriber = () => void
@@ -54,6 +55,7 @@ type NoticeSubscriber = () => void
const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = { const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
success: 3000, success: 3000,
info: 5000, info: 5000,
warning: 6000,
error: 8000, error: 8000,
} }
@@ -509,6 +511,8 @@ export const showNotice: ShowNotice = Object.assign(baseShowNotice, {
baseShowNotice('error', message, ...extras), baseShowNotice('error', message, ...extras),
info: (message: NoticeContent, ...extras: NoticeExtra[]) => info: (message: NoticeContent, ...extras: NoticeExtra[]) =>
baseShowNotice('info', message, ...extras), baseShowNotice('info', message, ...extras),
warning: (message: NoticeContent, ...extras: NoticeExtra[]) =>
baseShowNotice('warning', message, ...extras),
}) })
export function hideNotice(id: number) { export function hideNotice(id: number) {
+2
View File
@@ -324,6 +324,7 @@ export const translationKeys = [
'proxies.page.messages.directMode', 'proxies.page.messages.directMode',
'proxies.page.title.default', 'proxies.page.title.default',
'proxies.page.title.chainMode', 'proxies.page.title.chainMode',
'proxies.feedback.warnings.invalidTestUrl',
'proxies.feedback.notifications.provider.updateSuccess', 'proxies.feedback.notifications.provider.updateSuccess',
'proxies.feedback.notifications.provider.updateFailed', 'proxies.feedback.notifications.provider.updateFailed',
'proxies.feedback.notifications.provider.genericError', 'proxies.feedback.notifications.provider.genericError',
@@ -793,6 +794,7 @@ export const translationKeys = [
'settings.feedback.notifications.clash.changeFailed', 'settings.feedback.notifications.clash.changeFailed',
'settings.feedback.notifications.clash.geoDataUpdated', 'settings.feedback.notifications.clash.geoDataUpdated',
'settings.feedback.notifications.clashService.installSuccess', 'settings.feedback.notifications.clashService.installSuccess',
'settings.feedback.notifications.clashService.sidecarFallback',
'settings.feedback.notifications.clashService.uninstallSuccess', 'settings.feedback.notifications.clashService.uninstallSuccess',
'settings.feedback.notifications.updater.withClashProxySuccess', 'settings.feedback.notifications.updater.withClashProxySuccess',
'settings.feedback.notifications.updater.withClashProxyFailed', 'settings.feedback.notifications.updater.withClashProxyFailed',
+4
View File
@@ -500,6 +500,9 @@ export interface TranslationResources {
updateSuccess: string updateSuccess: string
} }
} }
warnings: {
invalidTestUrl: string
}
} }
page: { page: {
actions: { actions: {
@@ -831,6 +834,7 @@ export interface TranslationResources {
} }
clashService: { clashService: {
installSuccess: string installSuccess: string
sidecarFallback: string
uninstallSuccess: string uninstallSuccess: string
} }
updater: { updater: {
+1 -1
View File
@@ -5,7 +5,7 @@ const parseTraffic = (num?: number) => {
const exp = const exp =
num < 1 ? 0 : Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1) num < 1 ? 0 : Math.min(Math.floor(Math.log2(num) / 10), UNITS.length - 1)
const dat = num / Math.pow(1024, exp) const dat = num / Math.pow(1024, exp)
const ret = dat >= 1000 ? dat.toFixed(0) : dat.toPrecision(3) const ret = Math.round(dat) >= 1000 ? dat.toFixed(0) : dat.toPrecision(3)
const unit = UNITS[exp] const unit = UNITS[exp]
return [ret, unit] return [ret, unit]