Compare commits

..
Author SHA1 Message Date
renovate[bot] 438fc20142 chore(deps): update dependency pnpm to v12.18.0 2026-11-07 00:53:11 +08:00
Tunglies 7fd537af08 feat(settings): add searchable settings navigation 2026-11-06 17:14:15 +08:00
Tunglies 38faf20f90 feat(home): add core restart action to Clash info card 2026-11-06 17:14:06 +08:00
wonfen 87d9f95990 fix(profile): keep unquoted REALITY short-id as a string
Quote plain hex short-id values before YAML parsing, since a value like 1234e123 becomes a float that cannot be restored. Keep the rewrite only when nothing else changes, and let the editor save check a quoted copy.
2026-11-06 14:26:55 +08:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Tunglies
b9ab3b2015 chore(deps): update rust crate zip to 9.3.0 (#8567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-11-04 22:56:57 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a45c681389 chore(deps): update pnpm/action-setup action to v6.1.1 (#8566)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 22:51:08 +00:00
nrenvo a15ff6b448 ci: drop markdown from the frontend check paths (#8492) 2026-11-04 14:44:12 +00:00
nrenvo dae542f772 ci: regenerate the i18n types and check them for drift (#8494) 2026-11-04 14:43:16 +00:00
Tunglies 39ba1e64a6 chore(deps): remove unused dependencies (#8559)
- Drop tauri-plugin-process: no frontend or Rust consumer; exit and
  restart already go through the exit_app/restart_app commands backed
  by AppHandle::exit/restart. Remove the registration and the unused
  process:allow-exit capability.
- Replace the glob dev dependency with Node's built-in fs.globSync in
  prebuild's service permission step (Node baseline is 26.13.0).
- Remove unused direct Cargo declarations: bitflags and regex (source
  and workspace entries), and src-tauri's criterion dev-dependency;
  clash-verge-draft keeps criterion for its bench.
- Narrow sysinfo to the network/system features actually consumed.
- Use futures::StreamExt for DelayQueue in the timer and drop the
  tokio-stream direct declaration (still transitively present via
  clash-verge-service-ipc).
2026-11-04 09:23:16 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 27da92b5ff chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#8558)
Bumps the npm_and_yarn group with 3 updates in the / directory: [follow-redirects](https://github.com/follow-redirects/follow-redirects), [source-map-js](https://github.com/7rulnik/source-map-js) and [undici](https://github.com/nodejs/undici).


Updates `follow-redirects` from 1.16.0 to 1.16.2
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.2)

Updates `source-map-js` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

Updates `undici` from 6.28.1 to 6.29.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.28.1...v6.29.1)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: undici
  dependency-version: 6.29.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 09:22:25 +00:00
Tunglies f076d4cd5d fix(macos): preserve DNS and proxy state across network changes 2026-11-04 11:19:16 +08:00
Tunglies 97dc1622ee chore(updater): prefer GitHub before mirror endpoints
Relate #8529
2026-11-04 11:19:16 +08:00
Tunglies f65a7f1348 fix(tray): refresh icon after service operations
Refresh the tray menu, icon, and tooltip after service operations so restored TUN capability is reflected without changing the saved TUN setting.

Ref: e60285b4ae
2026-11-04 11:19:16 +08:00
wonfen 14abc4c4ee refactor(react): replace avoidable effects with derived state and handlers
Also drops a duplicate WebDAV history request on open and the per-keystroke DNS YAML dump.
2026-11-04 10:17:52 +08:00
wonfen 84bbe790e8 fix(proxies): move an unpicked GLOBAL off DIRECT to its first group
Keep DIRECT and REJECT listed in global mode.
2026-11-04 10:17:52 +08:00
renovate[bot]Tungliesrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e4d82a1b3f chore(deps): update tauri (#8388)
* chore(deps): update tauri

* fix(deps): restore compatible Specta and dialog dependencies

* fix(deps): align Specta release versions

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>

* fix(deps): regenerate Specta bindings and align consumers

* fix(deps): use the dialog plugin GTK backend

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
2026-11-03 23:56:44 +08:00
renovate[bot]Tungliesrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
9fb46607b5 chore(deps): update cargo dependencies (#8384)
* fix(webdav): allow clearing saved configuration

Close #8495

* chore(deps): update cargo dependencies

* fix(ci): resolve Rust dependency update failures

* chore(deps): remove unrelated changelog entries

---------

Co-authored-by: Tunglies <77394545+Tunglies@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-11-03 15:26:38 +00:00
wonfen 943e18e8ca fix(proxies): list only GLOBAL members in global mode
Reveal a group once GLOBAL selects it; hide DIRECT and REJECT unless selected.
2026-11-03 22:31:53 +08:00
Tunglies 8be05c178b fix(renovate): create Specta pull requests before CI 2026-11-03 22:28:56 +08:00
Tunglies 01fbc5714f style(renovate): format Specta package rules 2026-11-03 22:21:22 +08:00
Tunglies 5d41f99eb1 chore(renovate): group compatible Specta dependencies 2026-11-03 22:17:58 +08:00
Tunglies 1105eedeb6 fix(react): avoid redundant DNS and proxy updates (#8543) 2026-11-03 12:58:43 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 891757ede6 chore(deps): update dependency vite to v8.3.3 [security] (#8538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 12:36:14 +00:00
119 changed files with 3699 additions and 3674 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "26.13.0", "version": "26.13.0",
"pnpmVersion": "12.16.1" "pnpmVersion": "12.18.0"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1", "version": "1.99.1",
+5 -5
View File
@@ -43,7 +43,7 @@ jobs:
run: bash ./scripts/extract_update_logs.sh run: bash ./scripts/extract_update_logs.sh
shell: bash shell: bash
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.1
name: Install pnpm name: Install pnpm
with: with:
run_install: false run_install: false
@@ -174,7 +174,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.1
name: Install pnpm name: Install pnpm
with: with:
run_install: false run_install: false
@@ -255,7 +255,7 @@ jobs:
cache-workspace-crates: true cache-workspace-crates: true
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.1.0 uses: pnpm/action-setup@v6.1.1
with: with:
run_install: false run_install: false
@@ -388,7 +388,7 @@ jobs:
cache-workspace-crates: true cache-workspace-crates: true
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6.1.0 uses: pnpm/action-setup@v6.1.1
with: with:
run_install: false run_install: false
@@ -481,7 +481,7 @@ jobs:
with: with:
node-version: '26.13.0' node-version: '26.13.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.1
name: Install pnpm name: Install pnpm
with: with:
run_install: false run_install: false
+9 -1
View File
@@ -4,13 +4,13 @@ on:
pull_request: pull_request:
paths: paths:
- "src/**" - "src/**"
- "scripts/**"
- "**/*.js" - "**/*.js"
- "**/*.ts" - "**/*.ts"
- "**/*.tsx" - "**/*.tsx"
- "**/*.css" - "**/*.css"
- "**/*.scss" - "**/*.scss"
- "**/*.json" - "**/*.json"
- "**/*.md"
- "pnpm-lock.yaml" - "pnpm-lock.yaml"
- "pnpm-workspace.yaml" - "pnpm-workspace.yaml"
- "vite.config.*" - "vite.config.*"
@@ -41,6 +41,14 @@ jobs:
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Check generated i18n types are up to date
run: |
pnpm i18n:types
if ! git diff --exit-code -- src/types/generated; then
echo "::error::src/types/generated is stale; run pnpm i18n:types and commit the result"
exit 1
fi
- name: Run Prettier check - name: Run Prettier check
run: pnpm format:check run: pnpm format:check
+1 -1
View File
@@ -1,3 +1,3 @@
nodejs 26.13.0 nodejs 26.13.0
pnpm 12.16.1 pnpm 12.18.0
rust 1.99.1 rust 1.99.1
Generated
+188 -151
View File
@@ -150,7 +150,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation", "objc2-foundation",
"parking_lot", "parking_lot 0.12.5",
"percent-encoding", "percent-encoding",
"windows-sys 0.60.2", "windows-sys 0.60.2",
"wl-clipboard-rs", "wl-clipboard-rs",
@@ -159,9 +159,9 @@ dependencies = [
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -1089,7 +1089,6 @@ dependencies = [
"async-trait", "async-trait",
"backon", "backon",
"base64 0.23.1", "base64 0.23.1",
"bitflags 2.13.2",
"boa_engine", "boa_engine",
"bytes", "bytes",
"chrono", "chrono",
@@ -1100,7 +1099,6 @@ dependencies = [
"clash-verge-media-unlock", "clash-verge-media-unlock",
"clash-verge-signal", "clash-verge-signal",
"clash_verge_service_ipc", "clash_verge_service_ipc",
"criterion",
"dark-light", "dark-light",
"deelevate", "deelevate",
"dirs 7.0.0", "dirs 7.0.0",
@@ -1117,9 +1115,8 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"open", "open",
"parking_lot", "parking_lot 0.13.0",
"percent-encoding", "percent-encoding",
"regex",
"reqwest 0.13.5", "reqwest 0.13.5",
"reqwest_dav", "reqwest_dav",
"rfd", "rfd",
@@ -1149,7 +1146,6 @@ dependencies = [
"tauri-plugin-mihomo", "tauri-plugin-mihomo",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-wdio-webdriver", "tauri-plugin-wdio-webdriver",
@@ -1157,7 +1153,6 @@ dependencies = [
"tauri-specta", "tauri-specta",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-stream",
"tokio-util", "tokio-util",
"tracing", "tracing",
"warp", "warp",
@@ -1175,7 +1170,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"criterion", "criterion",
"parking_lot", "parking_lot 0.13.0",
"tokio", "tokio",
] ]
@@ -1198,7 +1193,7 @@ dependencies = [
"anyhow", "anyhow",
"flexi_logger", "flexi_logger",
"log", "log",
"parking_lot", "parking_lot 0.13.0",
"tracing-estuary", "tracing-estuary",
] ]
@@ -1255,8 +1250,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "facfbc410a385e0b0cbca8179dd9e363ed70e8455960bf7545137e90ca753b66" checksum = "facfbc410a385e0b0cbca8179dd9e363ed70e8455960bf7545137e90ca753b66"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
"lock_api", "lock_api 0.4.14",
"parking_lot_core", "parking_lot_core 0.9.12",
"polonius-the-crab", "polonius-the-crab",
] ]
@@ -1333,7 +1328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1767,8 +1762,18 @@ version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"darling_macro", "darling_macro 0.23.0",
]
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core 0.24.1",
"darling_macro 0.24.1",
] ]
[[package]] [[package]]
@@ -1784,17 +1789,41 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.23.0" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [ dependencies = [
"darling_core", "darling_core 0.23.0",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.5",
]
[[package]] [[package]]
name = "dashmap" name = "dashmap"
version = "6.2.1" version = "6.2.1"
@@ -1804,9 +1833,9 @@ dependencies = [
"cfg-if", "cfg-if",
"crossbeam-utils", "crossbeam-utils",
"hashbrown 0.14.5", "hashbrown 0.14.5",
"lock_api", "lock_api 0.4.14",
"once_cell", "once_cell",
"parking_lot_core", "parking_lot_core 0.9.12",
] ]
[[package]] [[package]]
@@ -1884,9 +1913,9 @@ dependencies = [
[[package]] [[package]]
name = "devtools-core" name = "devtools-core"
version = "0.4.1" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c53d416e473377276435d49d731a75d253b96ee4339831cd85653702b94d8298" checksum = "c9e85926bda763dd434fcfbcfb24af4b2c1a8c3e3c34bcd5d844c48f655cca51"
dependencies = [ dependencies = [
"bytes", "bytes",
"devtools-wire-format", "devtools-wire-format",
@@ -2014,7 +2043,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2273,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2789,7 +2818,7 @@ dependencies = [
"gobject-sys 0.21.5", "gobject-sys 0.21.5",
"libc", "libc",
"system-deps 7.0.8", "system-deps 7.0.8",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3343,7 +3372,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.61.2", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -3357,12 +3386,12 @@ dependencies = [
[[package]] [[package]]
name = "ico" name = "ico"
version = "0.5.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png 0.17.16", "png",
] ]
[[package]] [[package]]
@@ -3548,7 +3577,7 @@ dependencies = [
"byteorder-lite", "byteorder-lite",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png 0.18.1", "png",
"tiff", "tiff",
] ]
@@ -3857,7 +3886,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"libc", "libc",
"parking_lot", "parking_lot 0.12.5",
"path-tree", "path-tree",
"pin-project-lite", "pin-project-lite",
"rand 0.10.2", "rand 0.10.2",
@@ -4018,10 +4047,19 @@ dependencies = [
] ]
[[package]] [[package]]
name = "log" name = "lock_api"
version = "0.4.34" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@@ -4031,9 +4069,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.23.0" version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" checksum = "490750560fa7851fbbf5d3ab1a8750f09350ed361ed8aed85b1e1e40587812ee"
dependencies = [ dependencies = [
"sha2 0.11.1", "sha2 0.11.1",
] ]
@@ -4211,10 +4249,10 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png 0.18.1", "png",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4381,7 +4419,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4651,17 +4689,6 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-open-directory"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d"
dependencies = [
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-osa-kit" name = "objc2-osa-kit"
version = "0.3.2" version = "0.3.2"
@@ -4763,7 +4790,7 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [ dependencies = [
"parking_lot_core", "parking_lot_core 0.9.12",
] ]
[[package]] [[package]]
@@ -4786,9 +4813,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "open" name = "open"
version = "5.4.4" version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [ dependencies = [
"dunce", "dunce",
"is-wsl", "is-wsl",
@@ -4858,7 +4885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4922,8 +4949,18 @@ version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [ dependencies = [
"lock_api", "lock_api 0.4.14",
"parking_lot_core", "parking_lot_core 0.9.12",
]
[[package]]
name = "parking_lot"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226"
dependencies = [
"lock_api 0.5.0",
"parking_lot_core 0.10.0",
] ]
[[package]] [[package]]
@@ -4934,7 +4971,19 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
[[package]]
name = "parking_lot_core"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826"
dependencies = [
"libc",
"redox_syscall 0.9.4",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -5225,19 +5274,6 @@ dependencies = [
"plotters-backend", "plotters-backend",
] ]
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"
@@ -5523,7 +5559,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5667,6 +5703,15 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
] ]
[[package]]
name = "redox_syscall"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
dependencies = [
"bitflags 2.13.2",
]
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.4.6" version = "0.4.6"
@@ -5864,9 +5909,9 @@ dependencies = [
[[package]] [[package]]
name = "rfd" name = "rfd"
version = "0.16.0" version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [ dependencies = [
"block2", "block2",
"dispatch2", "dispatch2",
@@ -5874,16 +5919,18 @@ dependencies = [
"gobject-sys 0.18.0", "gobject-sys 0.18.0",
"gtk-sys", "gtk-sys",
"js-sys", "js-sys",
"libc",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"percent-encoding",
"raw-window-handle", "raw-window-handle",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6027,7 +6074,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6040,7 +6087,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6099,7 +6146,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6477,7 +6524,7 @@ version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [ dependencies = [
"darling", "darling 0.23.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -6719,7 +6766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6737,7 +6784,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall", "redox_syscall 0.5.18",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -6772,9 +6819,9 @@ dependencies = [
[[package]] [[package]]
name = "specta" name = "specta"
version = "2.0.0-rc.25" version = "2.0.0-rc.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" checksum = "ed155e448f42fc4be7a5cbc12bd43f6c549981f7c32fcba429ff6bc57cc4f30c"
dependencies = [ dependencies = [
"paste", "paste",
"rustc_version 0.4.1", "rustc_version 0.4.1",
@@ -6784,9 +6831,9 @@ dependencies = [
[[package]] [[package]]
name = "specta-macros" name = "specta-macros"
version = "2.0.0-rc.25" version = "2.0.0-rc.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" checksum = "b34b2516e16a9ddd3dc4e73cd6454cfe21fe209f8cb5a6f37346fe3774d759b3"
dependencies = [ dependencies = [
"Inflector", "Inflector",
"proc-macro2", "proc-macro2",
@@ -6796,9 +6843,9 @@ dependencies = [
[[package]] [[package]]
name = "specta-serde" name = "specta-serde"
version = "0.0.12" version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" checksum = "4983177ed0eb28235080261953de5f9ebae8a2d107851b5852420bd2ab246245"
dependencies = [ dependencies = [
"specta", "specta",
"specta-macros", "specta-macros",
@@ -6806,18 +6853,18 @@ dependencies = [
[[package]] [[package]]
name = "specta-typescript" name = "specta-typescript"
version = "0.0.12" version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" checksum = "3ecbe75dc1d27b36141b50b02b7c73bed00945710cee6edffa86d296a159a6f9"
dependencies = [ dependencies = [
"specta", "specta",
] ]
[[package]] [[package]]
name = "specta-util" name = "specta-util"
version = "0.0.12" version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" checksum = "d9bf531215ea70fba8264edecf98f12600462cf359f2038602a47aeda5ecd5b8"
dependencies = [ dependencies = [
"specta", "specta",
] ]
@@ -6847,7 +6894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [ dependencies = [
"new_debug_unreachable", "new_debug_unreachable",
"parking_lot", "parking_lot 0.12.5",
"phf_shared 0.13.1", "phf_shared 0.13.1",
"precomputed-hash", "precomputed-hash",
] ]
@@ -6983,16 +7030,15 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.39.6" version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [ dependencies = [
"libc", "libc",
"memchr", "memchr",
"ntapi", "ntapi",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"objc2-open-directory",
"windows 0.62.2", "windows 0.62.2",
] ]
@@ -7094,7 +7140,7 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation", "objc2-foundation",
"objc2-ui-kit", "objc2-ui-kit",
"parking_lot", "parking_lot 0.12.5",
"percent-encoding", "percent-encoding",
"raw-window-handle", "raw-window-handle",
"tao-macros", "tao-macros",
@@ -7148,9 +7194,9 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.12.4" version = "2.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -7203,9 +7249,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.7.3" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -7226,16 +7272,16 @@ dependencies = [
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.7.3" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"brotli", "brotli",
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png 0.18.1", "png",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver 1.0.28", "semver 1.0.28",
@@ -7253,9 +7299,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.7.3" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -7301,7 +7347,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"deelevate", "deelevate",
"libc", "libc",
"parking_lot", "parking_lot 0.13.0",
"serde", "serde",
"specta", "specta",
"sysinfo", "sysinfo",
@@ -7326,9 +7372,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.6.2" version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b3c64e24ebcbc63aaa2d88a9c51e2507084b46620baf39ecb0baa0738a08bb4" checksum = "ab551d9bec27bb79d1c7fe5ed38c5cfd1cebb86efa745acc65f5853f345f6e56"
dependencies = [ dependencies = [
"dunce", "dunce",
"plist", "plist",
@@ -7347,9 +7393,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-devtools" name = "tauri-plugin-devtools"
version = "2.2.2" version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1acb4d1b8490ee79166b72c3f8c989e066650a11aa94dfd0d71fb05c61cdbed0" checksum = "299a62101a16c14b914ebfaf529b4b64ad9ca534a0ece1c21fc9308d1e3ba9db"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"bytes", "bytes",
@@ -7374,9 +7420,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.8.3" version = "2.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@@ -7480,13 +7526,13 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.7.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b939d7b19c8af1381a72ef8474b3aba2b56c1ddac2c23f0c8e96abbcf86b198b" checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
"rand 0.9.5", "rand 0.10.2",
"serde", "serde",
"serde_json", "serde_json",
"serde_repr", "serde_repr",
@@ -7520,21 +7566,11 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-process"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.5.0" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" checksum = "7375b25f72f70f6168f27f1ba3a413e30eaa0a2098cff53a4046ebfe9e2f142d"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"libc", "libc",
@@ -7589,9 +7625,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-wdio-webdriver" name = "tauri-plugin-wdio-webdriver"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f76676d510d271d80abcf8b0552987b5b4f0eae5e3f906e9b5660d43c97c62e" checksum = "5a775b0b59f6f12b38d1f28667311a89cd07f532f540e27d65c6eadfba1156d8"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -7662,9 +7698,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.12.2" version = "2.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -7672,7 +7708,6 @@ dependencies = [
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"once_cell",
"percent-encoding", "percent-encoding",
"raw-window-handle", "raw-window-handle",
"softbuffer", "softbuffer",
@@ -7689,11 +7724,12 @@ dependencies = [
[[package]] [[package]]
name = "tauri-specta" name = "tauri-specta"
version = "2.0.0-rc.25" version = "2.0.0-rc.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" checksum = "fe71fc209fa4c49f959644f1ae2d70f72d5675bcb3d3ad8518122fa7b330994e"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"paste",
"serde", "serde",
"serde_json", "serde_json",
"specta", "specta",
@@ -7707,15 +7743,15 @@ dependencies = [
[[package]] [[package]]
name = "tauri-specta-macros" name = "tauri-specta-macros"
version = "2.0.0-rc.25" version = "2.0.0-rc.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" checksum = "dbf5689a1dbf2611e2351b79ba042ca089d0a3ce45274695e79084cd732a5aec"
dependencies = [ dependencies = [
"darling", "darling 0.24.1",
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.5",
] ]
[[package]] [[package]]
@@ -7785,10 +7821,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.3.4", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8041,9 +8077,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.3" version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -8399,7 +8435,7 @@ dependencies = [
"flexi_logger", "flexi_logger",
"log", "log",
"nu-ansi-term", "nu-ansi-term",
"parking_lot", "parking_lot 0.12.5",
"tokio", "tokio",
"tracing", "tracing",
"tracing-log", "tracing-log",
@@ -8451,10 +8487,10 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png 0.18.1", "png",
"serde", "serde",
"thiserror 2.0.20", "thiserror 2.0.20",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -8563,7 +8599,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [ dependencies = [
"memoffset", "memoffset",
"tempfile", "tempfile",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -9009,9 +9045,9 @@ dependencies = [
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -9105,7 +9141,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -9126,7 +9162,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"windows-sys 0.60.2", "windows-sys 0.61.2",
"windows-version", "windows-version",
] ]
@@ -9701,7 +9737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -10033,9 +10069,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "9.1.3" version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [ dependencies = [
"aes", "aes",
"bzip2", "bzip2",
@@ -10046,6 +10082,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"hmac", "hmac",
"indexmap 2.14.0", "indexmap 2.14.0",
"libc",
"lzma-rust2", "lzma-rust2",
"memchr", "memchr",
"nix", "nix",
+4 -6
View File
@@ -61,21 +61,20 @@ tracing-estuary = { git = "https://github.com/Tunglies/tracing-estuary.git", bra
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.44" tracing = "0.1.44"
tauri = { version = "2.12.4" } tauri = { version = "2.12.5" }
tauri-plugin-clipboard-manager = "2.5.0" tauri-plugin-clipboard-manager = "2.5.0"
parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] } parking_lot = "0.13.0"
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
criterion = { version = "0.8.2", features = ["async_tokio"] } criterion = { version = "0.8.2", features = ["async_tokio"] }
tokio = { version = "1.53.3", features = [ tokio = { version = "1.54.1", features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"time", "time",
"sync", "sync",
] } ] }
flexi_logger = "0.31.10" flexi_logger = "0.31.10"
log = "0.4.34" log = "0.4.35"
regex = "1.13.1"
reqwest = { version = "0.13.5", features = [ reqwest = { version = "0.13.5", features = [
"json", "json",
"cookies", "cookies",
@@ -90,7 +89,6 @@ smartstring = { version = "1.0.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_json = { version = "1.0.154" } serde_json = { version = "1.0.154" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" }
socket2 = "0.6.5" socket2 = "0.6.5"
# *** For Windows platform only *** # *** For Windows platform only ***
+12
View File
@@ -25,6 +25,13 @@
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复检测更新后又发布新版时,应用内更新失败 - 修复检测更新后又发布新版时,应用内更新失败
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
**🍎 macOS**
- 修复 macOS TUN 模式下原有 DNS 被清除、关闭后 DNS 未恢复及联网后设置未生效
- 修复 macOS 断网时误报代理已回退直连,恢复联网后自动重试系统代理
</details> </details>
@@ -37,5 +44,10 @@
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
**🍎 macOS**
- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
</details> </details>
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true } log = { workspace = true }
@@ -5,14 +5,14 @@ edition = "2024"
rust-version = "1.91" rust-version = "1.91"
[dependencies] [dependencies]
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function"] }
tauri = { workspace = true } tauri = { workspace = true }
tauri-plugin-clipboard-manager = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
# sysinfo 0.38.2 conflicts with dark-light # sysinfo 0.38.2 conflicts with dark-light
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623 # see https://github.com/GuillaumeGomez/sysinfo/issues/1623
sysinfo = { version = "0.39.6", features = ["network", "system"] } sysinfo = { version = "0.40.1", default-features = false, features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
libc = "0.2.191" libc = "0.2.191"
@@ -27,9 +27,9 @@ pub struct SysInfo {
impl Default for SysInfo { impl Default for SysInfo {
#[inline] #[inline]
fn default() -> Self { fn default() -> Self {
let system_name = System::name().unwrap_or_else(|| "Null".into()); let system_name = System::name().unwrap_or_else(|_| "Null".into());
let system_version = System::long_os_version().unwrap_or_else(|| "Null".into()); let system_version = System::long_os_version().unwrap_or_else(|_| "Null".into());
let system_kernel_version = System::kernel_version().unwrap_or_else(|| "Null".into()); let system_kernel_version = System::kernel_version().unwrap_or_else(|_| "Null".into());
let system_arch = System::cpu_arch(); let system_arch = System::cpu_arch();
Self { Self {
system_name, system_name,
@@ -130,9 +130,14 @@ pub fn current_gid() -> u32 {
#[inline] #[inline]
pub fn list_network_interfaces() -> Vec<String> { pub fn list_network_interfaces() -> Vec<String> {
let mut networks = Networks::new(); let Ok(mut networks) = Networks::new() else {
return Vec::new();
};
networks.refresh(false); networks.refresh(false);
networks.keys().map(|name| name.to_owned()).collect() networks
.keys()
.map(|name| name.to_string_lossy().into_owned())
.collect()
} }
#[inline] #[inline]
-34
View File
@@ -256,40 +256,6 @@ export default defineConfig([
}, },
}, },
}, },
{
files: ['scripts/react/*.{mjs,mts,ts,tsx}'],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.json',
},
},
settings: {
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: './scripts/react/tsconfig.json',
}),
],
},
},
{
files: ['scripts/react/*.{mjs,mts}'],
languageOptions: {
parserOptions: {
projectService: false,
project: './scripts/react/tsconfig.node.json',
},
},
},
{
files: ['scripts/react/{boundaries,material,scene}.tsx'],
// The isolated fixtures use full reloads and preserve aliased Hook names.
rules: {
'react-refresh/only-export-components': 'off',
'@eslint-react/no-unnecessary-use-prefix': 'off',
},
},
{ {
files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'], files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'],
rules: ipcBoundaryRules(), rules: ipcBoundaryRules(),
+1 -10
View File
@@ -1,15 +1,6 @@
{ {
"$schema": "https://unpkg.com/knip@6/schema.json", "$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [ "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"],
"src/polyfills/*.js!",
"scripts/check-contract-changes.mjs!",
"scripts/react/scene.tsx!",
"scripts/react/profiler.ts!",
"scripts/react/boundaries.tsx!",
"scripts/react/material.tsx!",
"scripts/react/yaml.ts!",
"scripts/react/wdyr.ts!"
],
"project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"],
"include": [ "include": [
"files", "files",
+6 -14
View File
@@ -40,11 +40,7 @@
"perf:build": "node scripts/perf/build.mjs", "perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs", "perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs", "perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs", "perf:test": "node --test scripts/perf/report.test.mjs"
"dev:profile": "cross-env CVR_REACT_DIAGNOSTICS=profiler node scripts/dev.mjs dev",
"react:ab": "node scripts/react/ab.mjs",
"dev:wdyr": "cross-env CVR_REACT_DIAGNOSTICS=wdyr node scripts/dev.mjs dev",
"audit:react": "node scripts/react/doctor.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/collision": "^0.5.0", "@dnd-kit/collision": "^0.5.0",
@@ -60,7 +56,7 @@
"@tanstack/react-virtual": "^3.14.13", "@tanstack/react-virtual": "^3.14.13",
"@tauri-apps/api": "2.12.4", "@tauri-apps/api": "2.12.4",
"@tauri-apps/plugin-clipboard-manager": "2.5.0", "@tauri-apps/plugin-clipboard-manager": "2.5.0",
"@tauri-apps/plugin-dialog": "2.8.3", "@tauri-apps/plugin-dialog": "2.8.4",
"@tauri-apps/plugin-fs": "2.7.1", "@tauri-apps/plugin-fs": "2.7.1",
"@tauri-apps/plugin-http": "2.8.3", "@tauri-apps/plugin-http": "2.8.3",
"@tauri-apps/plugin-opener": "2.7.2", "@tauri-apps/plugin-opener": "2.7.2",
@@ -98,7 +94,7 @@
"@biomejs/biome": "^2.5.14", "@biomejs/biome": "^2.5.14",
"@eslint-react/eslint-plugin": "^5.20.5", "@eslint-react/eslint-plugin": "^5.20.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tauri-apps/cli": "2.12.3", "@tauri-apps/cli": "2.12.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^26.0.0", "@types/node": "^26.0.0",
"@types/react": "19.3.0", "@types/react": "19.3.0",
@@ -106,7 +102,7 @@
"@types/validator": "^13.15.10", "@types/validator": "^13.15.10",
"@vitejs/plugin-legacy": "^8.2.3", "@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"@wdio/tauri-service": "1.5.0", "@wdio/tauri-service": "1.5.1",
"adm-zip": "^0.6.1", "adm-zip": "^0.6.1",
"axios": "^1.20.0", "axios": "^1.20.0",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
@@ -121,7 +117,6 @@
"eslint-plugin-react-refresh": "^0.5.7", "eslint-plugin-react-refresh": "^0.5.7",
"eslint-plugin-sukka": "^10.2.0", "eslint-plugin-sukka": "^10.2.0",
"eslint-plugin-unused-imports": "^4.4.1", "eslint-plugin-unused-imports": "^4.4.1",
"glob": "^14.0.0",
"globals": "^17.12.0", "globals": "^17.12.0",
"https-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
@@ -138,10 +133,7 @@
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"webdriverio": "10.1.0", "webdriverio": "10.1.0",
"ws": "8.22.1", "ws": "8.22.1"
"playwright": "1.64.1",
"@welldone-software/why-did-you-render": "10.0.1",
"react-doctor": "0.9.21"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,mjs}": [ "*.{ts,tsx,js,mjs}": [
@@ -153,5 +145,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.16.1" "packageManager": "pnpm@12.18.0"
} }
+251 -1225
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -113,6 +113,27 @@
"ghcr.io/devcontainers/features/rust" "ghcr.io/devcontainers/features/rust"
], ],
"groupName": "Rust toolchain" "groupName": "Rust toolchain"
},
{
"description": "Update the Specta contract toolchain together",
"matchManagers": ["cargo"],
"matchPackageNames": ["specta", "tauri-specta", "specta-typescript"],
"groupName": "specta",
"groupSlug": "specta",
"prCreation": "immediate",
"automerge": false
},
{
"description": "Keep Specta aligned with the supported tauri-specta release",
"matchManagers": ["cargo"],
"matchPackageNames": ["specta", "tauri-specta"],
"allowedVersions": "/^2\\.0\\.0-rc\\.26$/"
},
{
"description": "Keep the TypeScript exporter compatible with Specta rc.26",
"matchManagers": ["cargo"],
"matchPackageNames": ["specta-typescript"],
"allowedVersions": "/^0\\.0\\.13$/"
} }
], ],
"customManagers": [ "customManagers": [
+1 -2
View File
@@ -6,7 +6,6 @@ import path from 'node:path'
import zlib from 'node:zlib' import zlib from 'node:zlib'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { glob } from 'glob'
import { HttpsProxyAgent } from 'https-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent'
import { extract } from 'tar' import { extract } from 'tar'
@@ -519,7 +518,7 @@ const resolveServicePermission = async () => {
let hasChanges = false let hasChanges = false
for (const f of serviceExecutables) { for (const f of serviceExecutables) {
const files = glob.sync(path.join(SERVICE_DIR, f)) const files = fs.globSync(path.join(SERVICE_DIR, f))
for (const filePath of files) { for (const filePath of files) {
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
const currentHash = await calculateFileHash(filePath) const currentHash = await calculateFileHash(filePath)
-47
View File
@@ -1,47 +0,0 @@
# React diagnostics
`pnpm dev:profile` runs the usual development launcher with a root React
Profiler. In WebView DevTools, `window.__CVR_REACT_PROFILER__.read()` returns
copies of the latest 1,000 standard Profiler callback records; `.clear()` drops
them. Records contain timing and commit metadata, never business props. Normal
development and production builds do not load the collector.
`pnpm dev:wdyr` instead initializes why-did-you-render before loading the app
and uses its development JSX runtime. It reports avoidable renders for
`ProxyItem`, `ProxyItemMini` and `ProxyTunCard` in the console. The modes are
mutually exclusive; `CVR_REACT_DIAGNOSTICS` accepts only `profiler` or `wdyr` on
the development server. Each mode uses its own Vite cache. Production builds
ignore this variable and include neither diagnostic entry nor runtime.
`pnpm audit:react` scans supported source files under `src`. Pass file paths to
limit the scan, for example `pnpm audit:react src/hooks/use-proxy-delay-state.ts`;
add `--json` for the original structured report. Diagnostics are advisory.
Execution errors and incomplete scans fail the command. Telemetry, remote scores
and supply-chain scanning are disabled; the command does not fix code, erase
historical diagnostics or run in CI.
Install the browser explicitly with `pnpm exec playwright install chromium`.
Then run `pnpm react:ab --base <ref> --head <ref>`. Both Git trees run against
one installed dependency set and the same fixed DNS, delay-hook and proxy-tab
fixtures. The command never installs a browser or changes either checkout.
It retains source hashes, dependency identity, browser/environment information,
behavior assertions, render observations and profiles under `/tmp/cvr-react-ab-*`.
Each version and mode has separate Vite caches and output. Three pairs alternate
order in ordinary development, Strict Mode and production. A behavior failure
returns a nonzero exit status. DNS stale serialization and same-value renders
are observations so a known baseline can be compared with its fix. Append
`--assert dns` to require the head version to keep the latest serialized YAML in sync with
continuous field edits; this is the focused DNS regression assertion.
`--assert delay` requires same-value delay updates to retain their reference and memoized consumer,
while each changed field (including clearing elapsed time) propagates.
`--assert tabs` requires zero tab renders during unrelated parent updates. Use
`--assert dns --assert delay --assert tabs` for a cumulative candidate.
The fixtures execute the actual selected components and hook. IPC, data,
editor and UI rendering boundaries are replaced, and outgoing network requests
are rejected. DNS serialization is observed at `js-yaml.dump`; tab execution is
observed at the Material Paper boundary. These are component results, not native
Tauri integration, real DNS/proxy/TUN writes, or whole-application CPU/RSS gains.
Production React does not emit Profiler callbacks. Timing is diagnostic and has
no hard acceptance threshold; measure native release CPU/RSS separately and
calibrate A/A before claiming a performance gain. Keep raw reports out of Git.
-596
View File
@@ -1,596 +0,0 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cp,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
symlink,
writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import react from '@vitejs/plugin-react'
import { chromium } from 'playwright'
import { build, createServer, preview } from 'vite'
const { values } = parseArgs({
options: {
base: { type: 'string' },
head: { type: 'string' },
assert: { type: 'string', multiple: true },
},
})
if (!values.base || !values.head)
throw new Error('Usage: pnpm react:ab --base <ref> --head <ref>')
const assertions = new Set(values.assert ?? [])
for (const name of assertions) {
if (!['dns', 'delay', 'tabs'].includes(name))
throw new Error(`Unknown regression assertion: ${name}`)
}
const harness = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(harness, '../..')
const output = await mkdtemp('/tmp/cvr-react-ab-')
const dependencies = path.join(root, 'node_modules')
const frozenHarness = path.join(output, 'harness')
await cp(harness, frozenHarness, {
recursive: true,
filter: (file) => !path.basename(file).startsWith('tsconfig'),
})
const files = [
'src/components/setting/mods/dns-viewer.tsx',
'src/hooks/use-proxy-delay-state.ts',
'src/components/home/proxy-tun-card.tsx',
]
const hash = (content) => createHash('sha256').update(content).digest('hex')
function git(...args) {
const result = spawnSync('git', args, {
cwd: root,
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(result.stderr.toString())
return result.stdout
}
const manifest = {
assertions: [...assertions],
environment: {
platform: process.platform,
architecture: process.arch,
node: process.version,
os: os.release(),
lock: hash(await readFile(path.join(root, 'pnpm-lock.yaml'))),
},
harness: Object.fromEntries(
await Promise.all(
(await readdir(frozenHarness)).map(async (file) => [
file,
hash(await readFile(path.join(frozenHarness, file))),
]),
),
),
installedLock: hash(
await readFile(path.join(dependencies, '.pnpm/lock.yaml')),
),
sources: {},
runs: [],
}
let browser
try {
// Playwright intentionally fails here if its explicitly installed browser is missing.
browser = await chromium.launch({ headless: true })
manifest.environment.browser = browser.version()
for (const [variant, ref] of Object.entries({
base: values.base,
head: values.head,
})) {
const tree = git('rev-parse', '--verify', `${ref}^{tree}`).toString().trim()
const source = path.join(output, variant)
await mkdir(source)
const archive = git('archive', tree, 'src')
const extracted = spawnSync('tar', ['-x', '-C', source], { input: archive })
if (extracted.status !== 0) throw new Error(extracted.stderr.toString())
await cp(frozenHarness, path.join(source, 'fixture'), { recursive: true })
await symlink(dependencies, path.join(source, 'node_modules'), 'dir')
manifest.sources[variant] = {
ref,
tree,
files: Object.fromEntries(
await Promise.all(
files.map(async (file) => [
file,
hash(await readFile(path.join(source, file))),
]),
),
),
}
}
for (const mode of ['development', 'strict', 'production']) {
const servers = {}
try {
for (const variant of ['base', 'head']) {
const source = path.join(output, variant)
const fixture = path.join(source, 'fixture')
const boundary = path.join(fixture, 'boundaries.tsx')
const aliases = [
'@/hooks/use-clash',
'@/hooks/use-verge',
'@/hooks/use-profiles',
'@/hooks/use-system-state',
'@/hooks/use-system-proxy-state',
'@/services/states',
'@/services/cmds',
'@/services/mutate',
'@/services/notice-service',
'@/components/base',
'@/components/shared/proxy-control-switches',
'react-i18next',
].map((find) => ({ find, replacement: boundary }))
const config = {
configFile: false,
root: fixture,
cacheDir: path.join(output, 'cache', variant, mode),
plugins: [react()],
resolve: {
alias: [
...aliases,
{
find: '@/services/delay',
replacement: path.join(fixture, 'delay.ts'),
},
{
find: /^@mui\/material$/,
replacement: path.join(fixture, 'material.tsx'),
},
{
find: 'fixture-material',
replacement: path.join(dependencies, '@mui/material/index.mjs'),
},
{ find: 'js-yaml', replacement: path.join(fixture, 'yaml.ts') },
{
find: 'fixture-yaml',
replacement: path.join(
dependencies,
'js-yaml/dist/js-yaml.mjs',
),
},
{ find: '@', replacement: path.join(source, 'src') },
{
find: /^react(?=\/|$)/,
replacement: path.join(dependencies, 'react'),
},
{
find: /^react-dom(?=\/|$)/,
replacement: path.join(dependencies, 'react-dom'),
},
],
dedupe: ['react', 'react-dom'],
},
define: { OS_PLATFORM: JSON.stringify(process.platform) },
server: {
host: '127.0.0.1',
port: 0,
hmr: false,
fs: {
allow: [
output,
await realpath(output),
await realpath(dependencies),
],
},
},
build: {
outDir: path.join(output, 'dist', variant, mode),
emptyOutDir: true,
},
preview: { host: '127.0.0.1', port: 0 },
}
if (mode === 'production') {
await build(config)
servers[variant] = await preview(config)
} else {
servers[variant] = await createServer(config)
await servers[variant].listen()
}
}
for (const [pair, order] of [
['base', 'head'],
['head', 'base'],
['base', 'head'],
].entries()) {
for (const variant of order) {
const label = `${mode}-${pair + 1}-${variant}`
const context = await browser.newContext()
const errors = []
const address = servers[variant].httpServer.address()
const origin = `http://127.0.0.1:${address.port}`
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin === origin)
return route.continue()
errors.push(`Unexpected network: ${route.request().url()}`)
return route.abort()
})
const page = await context.newPage()
page.on('pageerror', (error) => errors.push(String(error)))
const result = { label, variant, mode, pair: pair + 1, errors }
try {
await page.goto(`${origin}/${mode === 'strict' ? '?strict' : ''}`)
await page.waitForSelector('#delay', { timeout: 60000 })
await page.waitForFunction(
() => window.__REACT_AB__.read().listeners.length === 1,
)
result.tabs = await tabs(page)
if (variant === 'head' && assertions.has('tabs')) {
assert.equal(
result.tabs.unrelated,
0,
'Unrelated parent updates must not rerender the memoized tabs',
)
}
result.dns = await dns(page)
if (variant === 'head' && assertions.has('dns')) {
for (const edit of result.dns.edits) {
assert.equal(
edit.serialized.at(-1),
edit.value,
'Latest DNS serialization must match the edited field',
)
}
}
result.delay = await delay(page)
if (variant === 'head' && assertions.has('delay')) {
for (const [index, observation] of result.delay.entries()) {
assert.equal(
observation.sameReference,
index === 1,
'Preserve the reference only when all three delay fields match',
)
}
assert.equal(
result.delay[1].childRenders,
result.delay[0].childRenders,
'Equal delay state must not rerender the memoized consumer',
)
}
result.profile = await page.evaluate(() => {
const before = window.__CVR_REACT_PROFILER__.read()
window.__CVR_REACT_PROFILER__.clear()
return { before, after: window.__CVR_REACT_PROFILER__.read() }
})
assert.equal(result.profile.after.length, 0)
result.collector = await page.evaluate(() =>
window.__REACT_AB__.checkCollector(),
)
assert.deepEqual(result.collector, {
length: 1000,
first: '5',
last: '1004',
copied: true,
cleared: true,
})
if (mode !== 'production')
assert(
result.profile.before.length > 0 &&
result.profile.before.length <= 1000,
)
assert.deepEqual(errors, [])
assert.deepEqual(
await page.evaluate(() =>
window.__REACT_AB__.fixture.notices.filter(
(notice) => notice.error,
),
),
[],
)
result.ok = true
} catch (error) {
result.snapshot = await page
.evaluate(() => window.__REACT_AB__?.read())
.catch(() => null)
result.ok = false
result.error = String(error)
throw error
} finally {
manifest.runs.push(result)
await writeFile(
path.join(output, `${label}.json`),
JSON.stringify(result, null, 2),
)
await context.close()
console.log(`${label}: ${result.ok ? 'pass' : 'FAIL'}`)
}
}
}
} finally {
for (const server of Object.values(servers)) {
if (server.close) await server.close()
else
await new Promise((resolve, reject) =>
server.httpServer.close((error) =>
error ? reject(error) : resolve(),
),
)
}
}
}
} finally {
await browser?.close()
await writeFile(
path.join(output, 'report.json'),
JSON.stringify(manifest, null, 2),
)
console.log(`Evidence: ${output}`)
}
async function settle(page) {
await page.waitForTimeout(100)
}
async function tabs(page) {
const buttons = page.locator('#tabs .MuiPaper-root')
const readTabs = () =>
page.evaluate(() =>
Object.fromEntries(
window.__REACT_AB__.fixture.tabRenders.map((tab) => [
tab.label.split('.').at(-1),
tab,
]),
),
)
assert.equal(await buttons.count(), 2)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
const before = await page.evaluate(
() => window.__REACT_AB__.fixture.tabRenders.length,
)
for (let index = 0; index < 5; index++) {
await page.evaluate(() => window.__REACT_AB__.update())
await settle(page)
}
const unrelated =
(await page.evaluate(() => window.__REACT_AB__.fixture.tabRenders.length)) -
before
await buttons.nth(1).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, false)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'tun',
)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert(
(await page.locator('#fixture-controls').textContent()).endsWith(
'toggles.tunMode',
),
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: true }))
await settle(page)
assert.equal((await readTabs()).tunMode.active, true)
assert.equal((await readTabs()).systemProxy.active, false)
await buttons.nth(0).click()
await settle(page)
assert.equal((await readTabs()).systemProxy.active, true)
assert.equal((await readTabs()).tunMode.active, false)
assert.equal(
await page.evaluate(() =>
localStorage.getItem('clash-verge-proxy-active-tab'),
),
'system',
)
await page.evaluate(() => {
window.__REACT_AB__.fixture.indicator = true
window.__REACT_AB__.fixture.verge.enable_tun_mode = true
window.__REACT_AB__.update()
})
await settle(page)
assert(Object.values(await readTabs()).every((tab) => tab.indicator))
await page.evaluate(() => {
window.__REACT_AB__.fixture.language = 'zh'
window.__REACT_AB__.update()
})
await settle(page)
const updated = Object.values(await readTabs())
assert(updated.every((tab) => tab.label.startsWith('zh:')))
assert(
(await page.locator('#tabs').textContent()).includes(
'zh:home.components.proxyTun.status.systemProxyEnabled',
),
)
const beforeColor = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
await page.evaluate(() => {
window.__REACT_AB__.fixture.theme = 'dark'
window.__REACT_AB__.update()
})
await settle(page)
const color = await buttons
.nth(0)
.evaluate((element) => getComputedStyle(element).backgroundColor)
assert.notEqual(color, beforeColor)
return { unrelated, updated, beforeColor, color }
}
async function dns(page) {
await page.evaluate(() => window.__REACT_AB__.open())
const listen = page.locator('#dns input[placeholder=":53"]')
await page.waitForFunction(
() =>
document.querySelector('#dns input[placeholder=":53"]')?.value ===
':5353',
)
const edits = []
for (const value of [':54', ':55', ':56']) {
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(value)
await settle(page)
assert.equal(await listen.inputValue(), value)
edits.push({
value,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map(
(config) => config.dns.listen,
),
),
})
}
await page.evaluate(() => {
window.__REACT_AB__.fixture.serialized.length = 0
})
await listen.fill(':')
await listen.pressSequentially('567', { delay: 0 })
await settle(page)
assert.equal(await listen.inputValue(), ':567')
edits.push({
value: ':567',
rapid: true,
serialized: await page.evaluate(() =>
window.__REACT_AB__.fixture.serialized.map((config) => config.dns.listen),
),
})
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 1,
)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.fixture.saved[0].dns.listen),
':567',
)
await page.evaluate(() => window.__REACT_AB__.open())
await page.waitForSelector('#dns')
await page
.getByRole('button', { name: /shared.actions.resetToDefault$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':53')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
const editor = page.locator('#fixture-monaco')
assert.match(await editor.inputValue(), /listen: ["']?:53/)
await editor.fill(
'dns:\n enable: true\n listen: ":8053"\nhosts:\n example.test: 127.0.0.1\n',
)
await page
.getByRole('button', { name: /shared.editorModes.visualization$/ })
.click()
await settle(page)
assert.equal(await listen.inputValue(), ':8053')
await page
.getByRole('button', { name: /shared.editorModes.advanced$/ })
.click()
await settle(page)
await page.locator('#save-dns').click()
await page.waitForFunction(
() => window.__REACT_AB__.fixture.saved.length === 2,
)
const saved = await page.evaluate(() => window.__REACT_AB__.fixture.saved)
assert.equal(saved[1].dns.listen, ':8053')
assert.equal(saved[1].hosts['example.test'], '127.0.0.1')
return { edits, saved }
}
async function delay(page) {
const updates = [
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 10, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 12, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 100 },
{ delay: 11, elapsed: 13, updatedAt: 101 },
{ delay: 11, updatedAt: 101 },
{ delay: -1, updatedAt: 102 },
{ delay: 0, updatedAt: 103 },
{ delay: 1000000, elapsed: 20, updatedAt: 104 },
]
const observations = []
for (const update of updates) {
await page.evaluate((value) => {
window.__REACT_AB__.remember()
window.__REACT_AB__.delay.emit('Node 1', 'Group 1', value)
}, update)
await settle(page)
const result = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(result.delay, update)
observations.push({
update,
sameReference: result.sameReference,
childRenders: result.childRenders,
})
}
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({ delay: 0, updatedAt: 200 }),
)
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
0,
)
await page.evaluate(() => window.__REACT_AB__.request())
await settle(page)
assert.equal(
await page.evaluate(() => window.__REACT_AB__.read().delay.delay),
-2,
)
await page.evaluate(() =>
window.__REACT_AB__.delay.finish({
delay: 1000000,
elapsed: 20,
updatedAt: 201,
}),
)
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().delay),
{ delay: 1000000, elapsed: 20, updatedAt: 201 },
)
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
let state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 1:Node 2'])
assert.deepEqual(state.delay, { delay: -1, updatedAt: 0 })
await page.evaluate(() => window.__REACT_AB__.update({ group: 'Group 2' }))
await settle(page)
state = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(state.listeners, ['Group 2:Node 2'])
for (const name of ['DIRECT', 'Unknown']) {
await page.evaluate((name) => window.__REACT_AB__.update({ name }), name)
await settle(page)
const preset = await page.evaluate(() => window.__REACT_AB__.read())
assert.deepEqual(preset.listeners, [])
assert.deepEqual(preset.delay, { delay: -1, updatedAt: 0 })
}
await page.evaluate(() => window.__REACT_AB__.update({ name: 'Node 2' }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
['Group 2:Node 2'],
)
await page.evaluate(() => window.__REACT_AB__.update({ mounted: false }))
await settle(page)
assert.deepEqual(
await page.evaluate(() => window.__REACT_AB__.read().listeners),
[],
)
return observations
}
-68
View File
@@ -1,68 +0,0 @@
import { Switch as MuiSwitch } from '@mui/material'
import { fixture } from './state'
const translations = {
en: (key: string) => `en:${key}`,
zh: (key: string) => `zh:${key}`,
}
export const useTranslation = () => ({
t: translations[fixture.language as 'en' | 'zh'],
})
export const useVerge = () => ({ verge: fixture.verge })
export const useProfiles = () => ({ current: { uid: 'fixture' } })
export const useClash = () => ({
mutateClash: () => {
throw new Error('Unexpected mutateClash')
},
})
export const useSystemState = () => ({ isTunModeAvailable: fixture.available })
export const useSystemProxyState = () => ({ indicator: fixture.indicator })
export const useThemeMode = () => fixture.theme
export const getDnsConfigContent = async () => {
fixture.commands.push('getDnsConfigContent')
return fixture.initialDns
}
export const saveDnsConfig = async (config: any) => {
fixture.commands.push('saveDnsConfig')
fixture.saved.push(structuredClone(config))
}
export const validateDnsConfig = async () => {
fixture.commands.push('validateDnsConfig')
return { status: 'valid' }
}
export const applyDnsConfig = () => {
throw new Error('Unexpected applyDnsConfig')
}
export const mutate = (fn: () => unknown) => fn()
export const showNotice = {
error: (...args: any[]) => fixture.notices.push({ error: args }),
success: (...args: any[]) => fixture.notices.push({ success: args }),
}
export const Switch = MuiSwitch
export function BaseDialog({ open, title, children, onClose, onOk }: any) {
return open ? (
<section id="dns">
{title}
<button id="close-dns" onClick={onClose}>
close
</button>
<button id="save-dns" onClick={onOk}>
save
</button>
{children}
</section>
) : null
}
export function MonacoEditor({ value, onChange }: any) {
return (
<textarea
id="fixture-monaco"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export default function ProxyControlSwitches({ label }: any) {
return <output id="fixture-controls">{label}</output>
}
-36
View File
@@ -1,36 +0,0 @@
type Update = { delay: number; elapsed?: number; updatedAt: number }
const listeners = new Map<string, (update: Update) => void>()
const cache = new Map<string, Update>()
let finish: ((update: Update) => void) | undefined
const key = (name: string, group: string) => `${group}:${name}`
export const delayFixture = {
listeners,
emit(name: string, group: string, update: Update) {
cache.set(key(name, group), update)
listeners.get(key(name, group))?.(update)
},
finish(update: Update) {
if (!finish) throw new Error('No pending delay request')
finish(update)
finish = undefined
},
}
export default {
setListener(name: string, group: string, listener: (update: Update) => void) {
listeners.set(key(name, group), listener)
},
removeListener(name: string, group: string) {
listeners.delete(key(name, group))
},
getDelayUpdate(name: string, group: string) {
return cache.get(key(name, group))
},
getDelayFix() {
return -1
},
checkDelay() {
return new Promise<Update>((resolve) => {
finish = resolve
})
},
}
-77
View File
@@ -1,77 +0,0 @@
import { spawnSync } from 'node:child_process'
import { statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import { globSync } from 'glob'
const { values, positionals } = parseArgs({
options: { json: { type: 'boolean' } },
allowPositionals: true,
})
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const files = positionals.length
? positionals
: globSync('src/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts,html}', {
cwd: root,
nodir: true,
}).sort()
if (files.length === 0) throw new Error('No source files found')
for (const file of files) {
if (
!/\.(?:[jt]sx?|[cm][jt]s|html)$/.test(file) ||
!statSync(path.resolve(root, file)).isFile()
)
throw new Error(`Expected a source file: ${file}`)
}
const executable = fileURLToPath(
new URL('../bin/react-doctor.js', import.meta.resolve('react-doctor')),
)
const result = spawnSync(
process.execPath,
[
executable,
...files,
'--json',
'--no-telemetry',
'--no-supply-chain',
'--blocking',
'none',
'--yes',
],
{
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 64 * 1024 * 1024,
},
)
if (result.error) throw result.error
if (result.signal)
throw new Error(`React Doctor terminated by ${result.signal}`)
const report = JSON.parse(result.stdout)
if (values.json) {
process.stdout.write(result.stdout)
} else {
for (const diagnostic of report.diagnostics) {
console.log(
`${diagnostic.filePath}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.plugin}/${diagnostic.rule}\n ${diagnostic.message}`,
)
}
console.log(
`${report.summary.totalDiagnosticCount} advisory diagnostics across ${report.projects.reduce((count, project) => count + project.analyzedFileCount, 0)} analyzed files`,
)
}
// In 0.9.21, --blocking none also masks linter execution failures.
const incomplete = report.projects.filter((project) => !project.complete)
if (report.error) console.error(report.error.message)
for (const project of incomplete) {
console.error(
'Incomplete React Doctor scan:',
project.directory,
project.skippedCheckReasons,
)
}
process.exitCode =
result.status || (!report.ok || incomplete.length > 0 ? 1 : 0)
-5
View File
@@ -1,5 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>React component A/B</title></head>
<body><div id="root"></div><script type="module" src="./scene.tsx"></script></body>
</html>
-16
View File
@@ -1,16 +0,0 @@
import { Paper as MuiPaper } from 'fixture-material'
export * from 'fixture-material'
import { fixture } from './state'
export function Paper(props: any) {
const { onClick, elevation, children, sx } = props
if (onClick && sx?.maxWidth === 160) {
fixture.tabRenders.push({
active: elevation === 2,
indicator: Boolean(children[2]),
label: children[1].props.children,
})
}
return <MuiPaper {...props} />
}
-19
View File
@@ -1,19 +0,0 @@
import type { ProfilerOnRenderCallback } from 'react'
export type ProfileRecord = Parameters<ProfilerOnRenderCallback>
const records: ProfileRecord[] = []
export const recordRender: ProfilerOnRenderCallback = (...record) => {
if (records.length === 1000) records.shift()
records.push(record)
}
export const profiler = {
read: () => records.map((record) => [...record]),
clear: () => {
records.length = 0
},
}
Object.assign(window, { __CVR_REACT_PROFILER__: profiler })
-124
View File
@@ -1,124 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import {
Profiler,
StrictMode,
memo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createRoot } from 'react-dom/client'
import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { DnsViewer } from '@/components/setting/mods/dns-viewer'
import { useProxyDelayState } from '@/hooks/use-proxy-delay-state'
import { delayFixture } from './delay'
import { profiler, recordRender } from './profiler'
import { fixture } from './state'
const member = (name: string): any =>
name === 'Unknown'
? {
kind: 'unresolved',
ref: { kind: 'unresolved', name, reason: 'missing' },
}
: {
kind: 'node',
ref: { kind: 'node', recordId: name, name },
node: { name, history: [], source: { kind: 'core', proxyName: name } },
}
let update: (patch?: any) => void
let open: () => void
let delay: ReturnType<typeof useProxyDelayState>
let previous: unknown
let childRenders = 0
const DelayValue = memo(({ state }: any) => {
childRenders++
return <output id="delay">{JSON.stringify(state)}</output>
})
function DelayProbe({ name, group }: any) {
const resolved = useMemo(() => member(name), [name])
const current = useProxyDelayState(resolved, group)
useEffect(() => {
delay = current
})
return <DelayValue state={current.delayState} />
}
function Scene() {
const [state, setState] = useState({
revision: 0,
name: 'Node 1',
group: 'Group 1',
mounted: true,
})
const ref = useRef<any>(null)
useEffect(() => {
update = (patch = {}) =>
setState((old) => ({ ...old, ...patch, revision: old.revision + 1 }))
open = () => ref.current.open()
}, [])
const mode = fixture.theme
const theme = useMemo(() => createTheme({ palette: { mode } }), [mode])
return (
<ThemeProvider theme={theme}>
<main data-revision={state.revision}>
<section id="tabs" key={state.mounted ? 'mounted' : 'reset'}>
<ProxyTunCard />
</section>
<DnsViewer ref={ref} />
{state.mounted && <DelayProbe name={state.name} group={state.group} />}
</main>
</ThemeProvider>
)
}
const strict = new URLSearchParams(location.search).has('strict')
const app = (
<Profiler id="fixtures" onRender={recordRender}>
<Scene />
</Profiler>
)
createRoot(document.getElementById('root')!).render(
strict ? <StrictMode>{app}</StrictMode> : app,
)
Object.assign(window, {
__REACT_AB__: {
update: (patch?: any) => update(patch),
open: () => open(),
fixture,
delay: delayFixture,
request: () => {
void delay.onDelay()
},
remember: () => {
previous = delay.delayState
},
checkCollector: () => {
profiler.clear()
for (let index = 0; index < 1005; index++)
recordRender(String(index), 'update', 1, 2, 3, 4)
const records = profiler.read()
records[0][0] = 'changed copy'
const preserved = profiler.read()
const result = {
length: preserved.length,
first: preserved[0][0],
last: preserved.at(-1)![0],
copied: preserved[0][0] === '5',
cleared: false,
}
profiler.clear()
result.cleared = profiler.read().length === 0
return result
},
read: () => ({
fixture,
profile: profiler.read(),
childRenders,
sameReference: previous === delay.delayState,
delay: delay.delayState,
listeners: [...delayFixture.listeners.keys()],
}),
},
})
-13
View File
@@ -1,13 +0,0 @@
export const fixture = {
language: 'en',
theme: 'light' as 'light' | 'dark',
verge: { enable_tun_mode: false },
indicator: false,
available: true,
initialDns: 'dns:\n enable: true\n listen: ":5353"\n',
saved: [] as any[],
serialized: [] as any[],
tabRenders: [] as any[],
notices: [] as any[],
commands: [] as string[],
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["../../src/*"],
"@root/*": ["../../*"],
"fixture-material": ["../../node_modules/@mui/material"],
"fixture-yaml": ["../../node_modules/js-yaml"]
}
},
"include": ["./*.ts", "./*.tsx", "../../src"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": true,
"types": ["node"]
},
"include": ["./*.mjs", "./*.mts"]
}
-61
View File
@@ -1,61 +0,0 @@
import path from 'node:path'
import { normalizePath, type Plugin } from 'vite'
export function reactDiagnostics(mode: string | undefined): Plugin[] {
if (!mode) return []
return [
{
name: 'react-diagnostics',
enforce: 'pre',
apply: 'serve',
config() {
if (mode !== 'profiler' && mode !== 'wdyr')
throw new Error(`Unknown CVR_REACT_DIAGNOSTICS mode: ${mode}`)
return {
cacheDir: path.resolve('node_modules/.vite', mode),
...(mode === 'wdyr'
? {
oxc: {
jsx: {
importSource: '@welldone-software/why-did-you-render',
},
},
optimizeDeps: {
include: [
'@welldone-software/why-did-you-render',
'@welldone-software/why-did-you-render/jsx-dev-runtime',
],
},
}
: {}),
}
},
transformIndexHtml(html) {
if (mode !== 'wdyr') return html
const entry = 'src="./main.tsx"'
if (!html.includes(entry))
throw new Error('WDYR entry no longer matches index.html')
return html.replace(
entry,
`src="/@fs/${normalizePath(path.resolve('scripts/react/wdyr.ts'))}"`,
)
},
transform(code, id) {
if (mode !== 'profiler') return
if (id !== normalizePath(path.resolve('src/main.tsx'))) return
const open = '<React.StrictMode>'
const close = '</React.StrictMode>'
if (!code.includes(open) || !code.includes(close)) {
throw new Error('React profiling entry no longer matches main.tsx')
}
return {
code: `import { Profiler } from 'react'\nimport { recordRender } from '../scripts/react/profiler'\n${code}`
.replace(open, `<Profiler id="app" onRender={recordRender}>${open}`)
.replace(close, `${close}</Profiler>`),
map: null,
}
},
},
]
}
-10
View File
@@ -1,10 +0,0 @@
import whyDidYouRender from '@welldone-software/why-did-you-render'
import React from 'react'
whyDidYouRender(React, {
include: [/^(ProxyItem|ProxyItemMini|ProxyTunCard)$/],
trackAllPureComponents: false,
trackHooks: true,
})
await import('../../src/main')
-9
View File
@@ -1,9 +0,0 @@
import * as yaml from 'fixture-yaml'
export * from 'fixture-yaml'
import { fixture } from './state'
export function dump(value: any, options: any) {
fixture.serialized.push(structuredClone(value))
return yaml.dump(value, options)
}
+50 -17
View File
@@ -32,7 +32,9 @@ function is_valid_ip() {
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. # Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() { function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" local dictionary
dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
sed -n "s/^ *$2 : //p" <<<"$dictionary"
} }
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. # Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
@@ -50,31 +52,62 @@ function dns_of() {
echo "$dns" echo "$dns"
} }
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1 [ $# -lt 1 ] && echo "Usage: $0 <newline-separated DNS servers> [legacy state directory]" >&2 && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1 dns_servers=()
while read -r server; do
! is_valid_ip "$server" && echo "$server is not a valid IP address." >&2 && exit 1
dns_servers+=("$server")
done <<<"$1"
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface. # Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) || exit 1
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) || exit 1
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1 [ -z "$hardware_port" ] && echo "No primary network service; waiting for the network." >&2 && exit 75
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; } original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
backup=".original_dns/$service_id" backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1 mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary. # Key old single-slot backups before import so a later network switch cannot reassign them.
if [ -f .original_dns.txt ]; then for state_dir in "$PWD" "${2:-$PWD}"; do
if [ -f "$backup" ]; then if [ -f "$state_dir/.original_dns.txt" ]; then
rm -f .original_dns.txt || exit 1 mkdir -p "$state_dir/.original_dns" || exit 1
else if [ -f "$state_dir/.original_dns/$service_id" ]; then
mv .original_dns.txt "$backup" || exit 1 rm -f "$state_dir/.original_dns.txt" || exit 1
else
mv "$state_dir/.original_dns.txt" "$state_dir/.original_dns/$service_id" || exit 1
fi
fi fi
done
if [ ! -f "$backup" ] && [ -n "$2" ] && [ -f "$2/$backup" ]; then
cp "$2/$backup" ".original_dns/.$service_id" || exit 1
mv ".original_dns/.$service_id" "$backup" || exit 1
fi
backup_header='# clash-verge-dns-managed'
function save_backup() {
printf '%s\n%s\n' "$backup_header" "$1" >".original_dns/.$service_id" &&
mv ".original_dns/.$service_id" "$backup"
}
saved_dns=
legacy_backup=false
if [ -f "$backup" ]; then
saved_dns=$(cat "$backup") || exit 1
case "$saved_dns" in
"$backup_header"$'\n'*) saved_dns=${saved_dns#*$'\n'} ;;
*) legacy_backup=true ;;
esac
fi fi
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1 # Only an unmarked historical backup owns the old single-address placeholder.
networksetup -setdnsservers "$hardware_port" "$1" if [ "$original_dns" != "114.114.114.114" ] || [ "$legacy_backup" != true ]; then
save_backup "$original_dns" || exit 1
legacy_backup=false
fi
networksetup -setdnsservers "$hardware_port" "${dns_servers[@]}"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; } [ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then fi
echo "empty" >"$backup" || exit 1 # Keep legacy ownership until the new list is verified, including a retry after a failed marker write.
if [ "$legacy_backup" = true ]; then
save_backup "$saved_dns" || exit 1
fi fi
+64 -35
View File
@@ -1,9 +1,11 @@
#!/bin/bash #!/bin/bash
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1 [ $# -lt 1 ] && echo "Usage: $0 <newline-separated DNS servers> [legacy state directory]" >&2 && exit 1
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. # Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() { function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" local dictionary
dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
sed -n "s/^ *$2 : //p" <<<"$dictionary"
} }
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. # Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
@@ -21,42 +23,69 @@ function dns_of() {
echo "$dns" echo "$dns"
} }
result=0 backup_header='# clash-verge-dns-managed'
# Older releases kept a single backup for whichever service was primary. completed_backups=()
if [ -f .original_dns.txt ]; then
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
fi
for backup in .original_dns/*; do function restore_dns() {
[ -f "$backup" ] || continue local result=0
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) # Older releases kept a single backup for whichever service was primary.
# Kept for a service that is gone: there is nothing to write it back to. if [ -f .original_dns.txt ]; then
[ -n "$hardware_port" ] || continue service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if ! current_dns=$(dns_of "$hardware_port"); then if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
echo "Cannot read DNS servers of $hardware_port." >&2 result=1
result=1 elif [ -f ".original_dns/$service_id" ]; then
continue rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
fi fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then for backup in .original_dns/*; do
original_dns=$(cat "$backup") || { result=1; continue; } [ -f "$backup" ] || continue
# Older releases could back up the placeholder itself. # Current backups take precedence even after their DNS has already been restored.
[ "$original_dns" = "$1" ] && original_dns=empty if [ "$PWD" != "$state_dir" ] && [ -f "$state_dir/$backup" ]; then
networksetup -setdnsservers "$hardware_port" $original_dns completed_backups+=("$PWD/$backup")
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then continue
echo "Failed to restore DNS for $hardware_port." >&2 fi
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) || { result=1; continue; }
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1 result=1
continue continue
fi fi
fi original_dns=$(cat "$backup") || { result=1; continue; }
rm -f "$backup" || result=1 legacy_backup=false
done case "$original_dns" in
rmdir .original_dns 2>/dev/null "$backup_header"$'\n'*) original_dns=${original_dns#*$'\n'} ;;
*) legacy_backup=true ;;
esac
# A service no longer on its recorded placeholder was changed by someone else.
if [ "$current_dns" = "$1" ] || { [ "$legacy_backup" = true ] && [ "$current_dns" = "114.114.114.114" ]; }; then
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
completed_backups+=("$PWD/$backup")
done
return $result
}
result=0
state_dir=$PWD
restore_dns "$1" || result=1
if [ -n "$2" ] && [ "$2" != "$PWD" ]; then
{ cd "$2" && restore_dns "$1"; } || result=1
fi
# Remove superseded legacy backups first; a cleanup failure must leave current authority intact.
if [ "$result" -eq 0 ]; then
for ((index=${#completed_backups[@]}-1; index>=0; index--)); do
rm -f "${completed_backups[index]}" || { result=1; break; }
done
fi
rmdir "$state_dir/.original_dns" "${2:-$state_dir}/.original_dns" 2>/dev/null
exit $result exit $result
+15 -20
View File
@@ -29,7 +29,7 @@ perf-harness = ["dep:tauri-plugin-wdio-webdriver"]
identifier = "io.github.clash-verge-rev.clash-verge-rev" identifier = "io.github.clash-verge-rev.clash-verge-rev"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.7.3", features = [] } tauri-build = { version = "2.7.4", features = [] }
[dependencies] [dependencies]
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
@@ -55,11 +55,10 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_yaml_ng = { workspace = true } serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] } smartstring = { workspace = true, features = ["serde"] }
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.4" open = "5.4.5"
rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] } rfd = { version = "0.17.2", 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"
@@ -67,19 +66,17 @@ chrono = { workspace = true }
boa_engine = "0.22.0" boa_engine = "0.22.0"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
reqwest = { workspace = true } reqwest = { workspace = true }
regex = { workspace = true }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [
"guard", "guard",
] } ] }
network-interface = { version = "2.0.5", features = ["serde"] } network-interface = { version = "2.0.5", features = ["serde"] }
tauri-plugin-shell = "2.5.0" tauri-plugin-shell = "2.6.0"
tauri-plugin-opener = "2.7.2" tauri-plugin-opener = "2.7.2"
tauri-plugin-dialog = "2.8.3" tauri-plugin-dialog = "2.8.4"
tauri-plugin-fs = "2.7.1" tauri-plugin-fs = "2.7.1"
tauri-plugin-process = "2.4.0" tauri-plugin-deep-link = "2.6.3"
tauri-plugin-deep-link = "2.6.2"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "2.5.0"
zip = "9.1.3" zip = "9.3.0"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
@@ -89,27 +86,26 @@ 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.7.0" tauri-plugin-notification = "2.8.0"
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.8.3" tauri-plugin-http = "2.8.3"
tracing = { workspace = true } tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true } tauri-plugin-devtools = { version = "2.2.3", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.3"
tokio-rustls = "0.26.6" tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] } rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.9" webpki-roots = "1.0.11"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
tokio-util = { version = "0.7.20", features = ["time"] } tokio-util = { version = "0.7.20", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] } specta = { version = "=2.0.0-rc.26", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } tauri-specta = { version = "=2.0.0-rc.26", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12" specta-typescript = "=0.0.13"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.191" libc = "0.2.191"
@@ -119,7 +115,7 @@ libc = "0.2.191"
clash-verge-signal = { workspace = true } clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.5.0", optional = true } tauri-plugin-wdio-webdriver = { version = "=1.5.1", optional = true }
objc2 = "0.6.5" objc2 = "0.6.5"
objc2-foundation = { version = "0.3.2", features = [ objc2-foundation = { version = "0.3.2", features = [
"NSString", "NSString",
@@ -156,7 +152,6 @@ tauri-plugin-global-shortcut = "2.4.1"
tauri-plugin-updater = "2.14.1" tauri-plugin-updater = "2.14.1"
[dev-dependencies] [dev-dependencies]
criterion = { workspace = true }
tokio = { workspace = true, features = ["test-util"] } tokio = { workspace = true, features = ["test-util"] }
warp = { version = "0.4.3", features = ["test"] } warp = { version = "0.4.3", features = ["test"] }
-1
View File
@@ -73,7 +73,6 @@
"global-shortcut:allow-register-all", "global-shortcut:allow-register-all",
"global-shortcut:allow-unregister", "global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all", "global-shortcut:allow-unregister-all",
"process:allow-exit",
"clipboard-manager:allow-read-text", "clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text", "clipboard-manager:allow-write-text",
"dialog:default" "dialog:default"
+53
View File
@@ -614,6 +614,53 @@ async fn select_node_with_timeout(group_name: &String, node: &String) -> Result<
.with_context(|| format!("failed to select node [{node}] for group [{group_name}]")) .with_context(|| format!("failed to select node [{node}] for group [{group_name}]"))
} }
// mihomo lists these first in GLOBAL, so an unpicked GLOBAL starts on DIRECT.
const GLOBAL_BUILTINS: [&str; 2] = ["DIRECT", "REJECT"];
/// GLOBAL's first visible group, else its first node, while GLOBAL is still on a built-in.
fn global_exit_target(proxies: &Proxies) -> Option<&str> {
let global = proxies.proxies.get("GLOBAL")?;
if !global.now.as_deref().is_some_and(|now| GLOBAL_BUILTINS.contains(&now)) {
return None;
}
let members = global.all.as_deref()?;
let is_visible_group = |name: &str| {
proxies.proxies.get(name).is_some_and(|proxy| {
proxy.hidden != Some(true)
&& matches!(
proxy.proxy_type,
ProxyType::Selector | ProxyType::URLTest | ProxyType::Fallback | ProxyType::LoadBalance
)
})
};
members
.iter()
.find(|name| is_visible_group(name))
.or_else(|| members.iter().find(|name| !GLOBAL_BUILTINS.contains(&name.as_str())))
.map(|name| name.as_str())
}
/// Moves GLOBAL off a built-in unless the profile records any GLOBAL choice.
pub(crate) async fn leave_default_global_exit() -> Result<()> {
let profiles = Config::profiles().await.data_arc();
let has_record = profiles
.current
.as_ref()
.and_then(|current| profiles.get_item(current).ok())
.and_then(|item| item.selected.as_deref())
.is_some_and(|selected| selected.iter().any(|item| item.name.as_deref() == Some("GLOBAL")));
if has_record {
return Ok(());
}
let snapshot = fetch_proxies_with_timeout().await?;
let Some(target) = global_exit_target(&snapshot) else {
return Ok(());
};
select_node_with_timeout(&String::from("GLOBAL"), &String::from(target)).await?;
logging!(info, Type::Config, "moved GLOBAL off its built-in default to {target}");
Ok(())
}
fn remaining_activations( fn remaining_activations(
activations: &[(String, String)], activations: &[(String, String)],
completed: &HashMap<String, String>, completed: &HashMap<String, String>,
@@ -1019,6 +1066,12 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot
.clone() .clone()
.unwrap_or_default(); .unwrap_or_default();
if Config::clash().await.latest_arc().get_mode().as_deref() == Some("global")
&& let Err(err) = leave_default_global_exit().await
{
logging!(warn, Type::Config, "failed to move GLOBAL off its default: {err:#}");
}
if selected.is_empty() { if selected.is_empty() {
if is_activation_current(generation) { if is_activation_current(generation) {
announce(Refresh::Clash); announce(Refresh::Clash);
+19 -5
View File
@@ -559,12 +559,13 @@ impl CoreManager {
})?; })?;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await; crate::utils::resolve::dns::sync_public_dns().await;
// At login the app usually beats the network. Leave the write to the network watcher // Only automatic restores can wait for the watcher; explicit requests must report failure.
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if crate::core::network_watch::is_armed() let defer_until_network = crate::core::network_watch::is_armed()
&& !crate::utils::resolve::is_resolve_done() && crate::core::notification::what_was_asked()
== crate::core::notification::FailedOperation::SystemProxyRestore;
#[cfg(target_os = "macos")]
if defer_until_network
&& Config::verge() && Config::verge()
.await .await
.latest_arc() .latest_arc()
@@ -579,7 +580,20 @@ impl CoreManager {
); );
return Ok(()); return Ok(());
} }
#[cfg(not(target_os = "macos"))]
proxy_control::apply().await?; proxy_control::apply().await?;
#[cfg(target_os = "macos")]
if let Err(error) = proxy_control::apply().await {
if defer_until_network && proxy_control::is_missing_network_service_failure(&error) {
logging!(
debug,
Type::Core,
"network service disappeared; the system proxy waits for its return"
);
return Ok(());
}
return Err(error);
}
if !expectation.is_valid( if !expectation.is_valid(
self.get_running_mode().as_ref(), self.get_running_mode().as_ref(),
self.current_core_readiness_generation(), self.current_core_readiness_generation(),
+1
View File
@@ -49,6 +49,7 @@ async fn reapply() {
.enable_system_proxy .enable_system_proxy
.unwrap_or_default() .unwrap_or_default()
{ {
crate::utils::resolve::dns::sync_public_dns().await;
return; return;
} }
if !proxy_control::has_network_service().await { if !proxy_control::has_network_service().await {
+24 -4
View File
@@ -180,6 +180,14 @@ pub const fn is_missing_network_service(_error: &sysproxy::Error) -> bool {
false false
} }
pub fn is_missing_network_service_failure(error: &anyhow::Error) -> bool {
!SystemProxyStateUnknown::is_in(error)
&& SysproxyFailure::from_chain(error).is_none()
&& error
.downcast_ref::<sysproxy::Error>()
.is_some_and(is_missing_network_service)
}
/// Distinguish a misplaced sidecar from a missing privileged service. /// Distinguish a misplaced sidecar from a missing privileged service.
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error { async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) { if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
@@ -359,6 +367,13 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()), ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
// Direct fallback satisfies a disable request. // Direct fallback satisfies a disable request.
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()), ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
// Older helpers encode a failed service lookup as fallback even when no OS write occurred.
#[cfg(target_os = "macos")]
ProxyApplyOutcome::DirectFallback { message }
if message == sysproxy::Error::NoActiveNetworkService.to_string() =>
{
Err(sysproxy::Error::NoActiveNetworkService.into())
}
ProxyApplyOutcome::DirectFallback { message } => { ProxyApplyOutcome::DirectFallback { message } => {
Err(SysproxyFailure::DirectFallback { detail: message }.into()) Err(SysproxyFailure::DirectFallback { detail: message }.into())
} }
@@ -609,6 +624,10 @@ pub async fn refresh_guard() -> Result<()> {
logging!(debug, Type::Core, "proxy guard superseded (generation {generation})"); logging!(debug, Type::Core, "proxy guard superseded (generation {generation})");
break; break;
} }
Err(error) if is_missing_network_service_failure(&error) => {
consecutive_failures = 0;
logging!(debug, Type::Core, "system proxy guard waits for a network service");
}
Err(error) => { Err(error) => {
consecutive_failures += 1; consecutive_failures += 1;
logging!( logging!(
@@ -1110,10 +1129,11 @@ mod tests {
.err() .err()
.unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail")); .unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail"));
assert_eq!( assert!(super::is_missing_network_service_failure(&error));
SysproxyFailure::from_chain(&error).map(SysproxyFailure::code), assert!(SysproxyFailure::from_chain(&error).is_none());
Some("SYSPROXY_DIRECT_FALLBACK") assert!(!super::is_missing_network_service_failure(
); &error.context(SystemProxyStateUnknown)
));
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
+1 -1
View File
@@ -1970,7 +1970,7 @@ impl ServiceManager {
async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> { async fn run_operation(&self, operation: impl Future<Output = Result<()>>) -> Result<()> {
run_operation_and_then(&RUN_STATE, operation, || async { run_operation_and_then(&RUN_STATE, operation, || async {
if let Err(error) = Tray::global().update_menu().await { if let Err(error) = Tray::global().update_part().await {
logging!( logging!(
warn, warn,
Type::Service, Type::Service,
+2 -2
View File
@@ -7,6 +7,7 @@ use crate::{
}; };
use anyhow::Result; use anyhow::Result;
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use futures::StreamExt as _;
use parking_lot::{Mutex, RwLock}; use parking_lot::{Mutex, RwLock};
use smartstring::alias::String; use smartstring::alias::String;
use std::{ use std::{
@@ -21,7 +22,6 @@ use tokio::{
sync::mpsc, sync::mpsc,
time::{sleep, timeout}, time::{sleep, timeout},
}; };
use tokio_stream::StreamExt as _;
use tokio_util::time::{DelayQueue, delay_queue::Key}; use tokio_util::time::{DelayQueue, delay_queue::Key};
enum TimerCommand { enum TimerCommand {
@@ -501,6 +501,7 @@ impl Timer {
mod tests { mod tests {
use super::{TaskSchedule, Timer, run_timer_profile_update_transition}; use super::{TaskSchedule, Timer, run_timer_profile_update_transition};
use crate::config::{PrfItem, PrfOption}; use crate::config::{PrfItem, PrfOption};
use futures::StreamExt as _;
use parking_lot::Mutex; use parking_lot::Mutex;
use smartstring::alias::String; use smartstring::alias::String;
use std::collections::HashMap; use std::collections::HashMap;
@@ -513,7 +514,6 @@ mod tests {
time::Duration, time::Duration,
}; };
use tokio::sync::Barrier; use tokio::sync::Barrier;
use tokio_stream::StreamExt as _;
use tokio_util::time::DelayQueue; use tokio_util::time::DelayQueue;
struct CancellationProbe { struct CancellationProbe {
+31 -2
View File
@@ -1,10 +1,13 @@
use anyhow::Result; use anyhow::Result;
use nanoid::nanoid;
use scopeguard::defer; use scopeguard::defer;
use serde::Serialize; use serde::Serialize;
use smartstring::alias::String; use smartstring::alias::String;
use std::{ use std::{
borrow::Cow,
fmt, fmt,
hash::{DefaultHasher, Hash as _, Hasher as _}, hash::{DefaultHasher, Hash as _, Hasher as _},
path::Path,
sync::LazyLock, sync::LazyLock,
sync::atomic::{AtomicBool, Ordering}, sync::atomic::{AtomicBool, Ordering},
}; };
@@ -329,7 +332,7 @@ impl CoreConfigValidator {
Ok(result) => result, Ok(result) => result,
Err(err) => { Err(err) => {
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err); logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
return Self::validate_config_internal_outcome(config_path).await; return Self::validate_profile_yaml_outcome(config_path).await;
} }
}; };
@@ -344,7 +347,33 @@ impl CoreConfigValidator {
} }
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path); logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
Self::validate_config_internal_outcome(config_path).await Self::validate_profile_yaml_outcome(config_path).await
}
/// The user file is saved as written, so the core checks a copy carrying the tolerated short-ids.
async fn validate_profile_yaml_outcome(config_path: &str) -> Result<ValidationOutcome> {
let Some(tolerant) = Self::short_id_tolerant_text(config_path).await else {
return Self::validate_config_internal_outcome(config_path).await;
};
let Ok(app_dir) = dirs::app_home_dir() else {
return Self::validate_config_internal_outcome(config_path).await;
};
let check_path = app_dir.join(format!("short-id-check-{}.yaml", nanoid!()));
defer! {
let _ = std::fs::remove_file(&check_path);
}
if help::save_yaml_str(&check_path, &tolerant).await.is_err() {
return Self::validate_config_internal_outcome(config_path).await;
}
Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await
}
async fn short_id_tolerant_text(config_path: &str) -> Option<std::string::String> {
let yaml = fs::read_to_string(config_path).await.ok()?;
match help::short_id_tolerant(&yaml, Path::new(config_path)) {
Ok((Cow::Owned(tolerant), _)) => Some(tolerant),
_ => None,
}
} }
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> { async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
+52 -1
View File
@@ -3,8 +3,10 @@ use crate::{
config::PrfItem, config::PrfItem,
utils::{dirs, help}, utils::{dirs, help},
}; };
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping; use serde_yaml_ng::Mapping;
use smartstring::alias::String; use smartstring::alias::String;
use std::{borrow::Cow, path::Path};
use tokio::fs; use tokio::fs;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -48,7 +50,8 @@ impl AsyncChainItemFrom for Option<ChainItem> {
data: ChainType::Merge(help::read_mapping(&path).await.ok()?), data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}), }),
"rules" | "proxies" | "groups" => { "rules" | "proxies" | "groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?; let yaml = fs::read_to_string(&path).await.ok()?;
let seq_map = parse_seq_map(&yaml, &path)?;
let data = match itype { let data = match itype {
"rules" => ChainType::Rules(seq_map), "rules" => ChainType::Rules(seq_map),
"proxies" => ChainType::Proxies(seq_map), "proxies" => ChainType::Proxies(seq_map),
@@ -60,3 +63,51 @@ impl AsyncChainItemFrom for Option<ChainItem> {
} }
} }
} }
/// Typed parsing reads `delete: [1234]` as strings where a `Value` round trip would reject it.
fn parse_seq_map(yaml: &str, path: &Path) -> Option<SeqMap> {
let original = serde_yaml_ng::from_str::<SeqMap>(yaml).ok()?;
let Ok((Cow::Owned(tolerant), _)) = help::short_id_tolerant(yaml, path) else {
return Some(original);
};
match serde_yaml_ng::from_str::<SeqMap>(&tolerant) {
Ok(seq_map) => Some(seq_map),
Err(_) => {
logging!(
warn,
Type::Config,
"kept numeric short-id in {}: quoted text does not load",
path.display()
);
Some(original)
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::parse_seq_map;
use serde_yaml_ng::Value;
use std::path::Path;
#[test]
fn seq_short_id_keeps_typed_parse() {
for (input, delete) in [
("prepend: []\nappend: []\ndelete: [1234]\n", "1234"),
("prepend: []\nappend: []\ndelete: [true]\n", "true"),
] {
let seq_map = parse_seq_map(input, Path::new("b8.yaml")).expect("seq file should load");
assert_eq!(seq_map.delete, [delete]);
}
let input = "prepend:\n - name: r\n reality-opts:\n short-id: 1234e123\nappend: []\ndelete: []\n";
let seq_map = parse_seq_map(input, Path::new("seq.yaml")).expect("seq file should load");
let short_id = seq_map
.prepend
.first()
.and_then(|proxy| proxy.get("reality-opts"))
.and_then(|opts| opts.get("short-id"));
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
}
}
+6
View File
@@ -66,6 +66,12 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> {
return Err(err.to_string().into()); return Err(err.to_string().into());
} }
if mode.as_str() == "global"
&& let Err(err) = crate::config::profiles::leave_default_global_exit().await
{
logging!(warn, Type::Core, "failed to move GLOBAL off its default: {err:#}");
}
let clash = Config::clash().await; let clash = Config::clash().await;
let save_result = { let save_result = {
let _config_write = Config::lock_config_write().await; let _config_write = Config::lock_config_write().await;
-1
View File
@@ -54,7 +54,6 @@ mod app_init {
.build(), .build(),
) )
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
+285 -2
View File
@@ -7,6 +7,8 @@ use scopeguard::{ScopeGuard, guard};
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use serde_yaml_ng::{Mapping, Value}; use serde_yaml_ng::{Mapping, Value};
use std::{ use std::{
borrow::Cow,
ops::Range,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
}; };
@@ -45,8 +47,8 @@ pub async fn read_mapping(path: &Path) -> Result<Mapping> {
} }
fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> { fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&yaml_str) { match short_id_tolerant(&yaml_str, &path) {
Ok(mut val) => { Ok((_, mut val)) => {
val.apply_merge() val.apply_merge()
.with_context(|| format!("failed to apply merge \"{}\"", path.display()))?; .with_context(|| format!("failed to apply merge \"{}\"", path.display()))?;
@@ -66,6 +68,150 @@ fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
} }
} }
const SHORT_ID_KEY: &str = "short-id:";
/// A float like `1234e123` loses its hex text, so quote short-ids before parsing and keep that only if nothing else changed.
pub(crate) fn short_id_tolerant<'a>(yaml: &'a str, path: &Path) -> serde_yaml_ng::Result<(Cow<'a, str>, Value)> {
let original = serde_yaml_ng::from_str::<Value>(yaml)?;
if !has_numeric_short_id(&original) {
return Ok((Cow::Borrowed(yaml), original));
}
let quoted = quote_plain_short_ids(yaml);
if matches!(quoted, Cow::Borrowed(_)) {
return Ok((quoted, original));
}
match serde_yaml_ng::from_str::<Value>(&quoted) {
Ok(tolerant) if same_except_short_id_text(&original, &tolerant) => Ok((quoted, tolerant)),
_ => {
logging!(
warn,
Type::Config,
"kept numeric short-id in {}: quoting it would change other values",
path.display()
);
Ok((Cow::Borrowed(yaml), original))
}
}
}
fn has_numeric_short_id(value: &Value) -> bool {
match value {
Value::Mapping(map) => map
.iter()
.any(|(key, value)| (is_short_id_key(key) && value.is_number()) || has_numeric_short_id(value)),
Value::Sequence(seq) => seq.iter().any(has_numeric_short_id),
Value::Tagged(tagged) => has_numeric_short_id(&tagged.value),
_ => false,
}
}
fn same_except_short_id_text(original: &Value, tolerant: &Value) -> bool {
match (original, tolerant) {
(Value::Mapping(original), Value::Mapping(tolerant)) => {
original.len() == tolerant.len()
&& original
.iter()
.zip(tolerant.iter())
.all(|((key, old), (new_key, new))| {
key == new_key && (same_except_short_id_text(old, new) || is_short_id_text(key, old, new))
})
}
(Value::Sequence(original), Value::Sequence(tolerant)) => {
original.len() == tolerant.len()
&& original
.iter()
.zip(tolerant.iter())
.all(|(old, new)| same_except_short_id_text(old, new))
}
(Value::Tagged(original), Value::Tagged(tolerant)) => {
original.tag == tolerant.tag && same_except_short_id_text(&original.value, &tolerant.value)
}
_ => original == tolerant,
}
}
fn is_short_id_text(key: &Value, old: &Value, new: &Value) -> bool {
is_short_id_key(key)
&& old.is_number()
&& matches!(new, Value::String(text) if serde_yaml_ng::from_str::<Value>(text).ok().as_ref() == Some(old))
}
fn is_short_id_key(key: &Value) -> bool {
key.as_str() == Some("short-id")
}
fn quote_plain_short_ids(yaml: &str) -> Cow<'_, str> {
let mut quoted = String::new();
let mut copied = 0;
for (key, _) in yaml.match_indices(SHORT_ID_KEY) {
let Some(value) = plain_short_id_at(yaml, key) else {
continue;
};
let (Some(head), Some(id)) = (yaml.get(copied..value.start), yaml.get(value.clone())) else {
continue;
};
quoted.push_str(head);
quoted.push('"');
quoted.push_str(id);
quoted.push('"');
copied = value.end;
}
if copied == 0 {
return Cow::Borrowed(yaml);
}
quoted.push_str(yaml.get(copied..).unwrap_or_default());
Cow::Owned(quoted)
}
fn plain_short_id_at(yaml: &str, key: usize) -> Option<Range<usize>> {
let bytes = yaml.as_bytes();
let before = bytes.get(..key)?;
let flow = matches!(
before.iter().rev().find(|byte| !byte.is_ascii_whitespace()),
Some(b'{' | b',')
);
let line_start = before
.iter()
.rev()
.take_while(|&&byte| byte != b'\n')
.all(|&byte| is_blank(byte));
if !flow && !line_start {
return None;
}
let start = key + SHORT_ID_KEY.len();
let gap = bytes.get(start..)?.iter().take_while(|&&byte| is_blank(byte)).count();
if gap == 0 {
return None;
}
let start = start + gap;
let len = bytes
.get(start..)?
.iter()
.take_while(|byte| byte.is_ascii_hexdigit())
.count();
if len == 0 || !ends_plain_short_id(bytes.get(start + len..)?, flow) {
return None;
}
Some(start..start + len)
}
fn ends_plain_short_id(rest: &[u8], flow: bool) -> bool {
let blanks = rest.iter().take_while(|&&byte| is_blank(byte)).count();
match rest.get(blanks..) {
Some([] | [b'\n', ..] | [b'\r', b'\n', ..]) => true,
Some([b'#', ..]) => blanks > 0,
Some([b',' | b'}', ..]) => flow,
_ => false,
}
}
const fn is_blank(byte: u8) -> bool {
matches!(byte, b' ' | b'\t')
}
pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> { pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> {
let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?; let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?;
@@ -319,3 +465,140 @@ pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
Ok(temp_path) Ok(temp_path)
} }
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{has_numeric_short_id, parse_mapping, quote_plain_short_ids, short_id_tolerant};
use serde_yaml_ng::Value;
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
#[test]
fn quotes_plain_short_ids() {
let cases = [
("short-id: 1234e123", "short-id: \"1234e123\""),
("short-id: 0e5", "short-id: \"0e5\""),
("short-id: 12345678", "short-id: \"12345678\""),
("short-id: 6ba85179", "short-id: \"6ba85179\""),
("short-id: \"1234e123\"", "short-id: \"1234e123\""),
("short-id: '1234e123'", "short-id: '1234e123'"),
("short-id: \"\"", "short-id: \"\""),
("short-id:\npublic-key: k", "short-id:\npublic-key: k"),
("short-id: null", "short-id: null"),
("short-id: ~", "short-id: ~"),
("short-id: 1234e123 # note", "short-id: \"1234e123\" # note"),
("# short-id: 1234e123", "# short-id: 1234e123"),
(
"reality-opts: {public-key: k, short-id: 1234e123}",
"reality-opts: {public-key: k, short-id: \"1234e123\"}",
),
(
"reality-opts: {short-id: 1234e123, public-key: k}",
"reality-opts: {short-id: \"1234e123\", public-key: k}",
),
(
"{\n public-key: k,\n short-id: 1234e123\n}",
"{\n public-key: k,\n short-id: \"1234e123\"\n}",
),
("short-id: &sid 1234e123", "short-id: &sid 1234e123"),
("short-id: !!str 1234e123", "short-id: !!str 1234e123"),
("short-id: abc,def", "short-id: abc,def"),
("short-id: 1234e123\r\n", "short-id: \"1234e123\"\r\n"),
("my-short-id: 1234e123", "my-short-id: 1234e123"),
("xshort-id: 1", "xshort-id: 1"),
("short-id:1234e123", "short-id:1234e123"),
("short-id: 1.5e3", "short-id: 1.5e3"),
];
for (input, expected) in cases {
let output = quote_plain_short_ids(input);
assert_eq!(output, expected, "input: {input:?}");
assert_eq!(
matches!(output, Cow::Borrowed(_)),
input == expected,
"input: {input:?}"
);
assert_eq!(quote_plain_short_ids(&output), expected, "rerun: {input:?}");
}
}
#[test]
fn plain_short_id_survives_profile_parse() {
let profile = "\
proxies:
- name: r
type: vless
reality-opts:
public-key: SFv3pZQdZ1fpnNg9yI2RwXKqAOz4mZHRbT2Wq4kz8Ws
short-id: 1234e123
";
let mapping = parse_mapping(profile.into(), PathBuf::from("profile.yaml")).expect("profile should parse");
let short_id = mapping
.get("proxies")
.and_then(|proxies| proxies.get(0))
.and_then(|proxy| proxy.get("reality-opts"))
.and_then(|opts| opts.get("short-id"));
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
let output = serde_yaml_ng::to_string(&mapping).expect("mapping should serialize");
assert!(output.contains("short-id: '1234e123'"), "{output}");
}
#[test]
fn short_id_rewrite_falls_back_to_original_parse() {
// (case, input, has a numeric short-id today)
let cases = [
("B2", "proxies:\n - name: \"node, short-id: 1234e123}\"\n", false),
(
"B3",
"proxies:\n - name: \"node, short-id: 1234e123}\"\n - name: r\n reality-opts:\n short-id: 1234e123\n",
true,
),
(
"B4",
"payload: |\n short-id: 1234e123\nreality-opts:\n short-id: 1234e123\n",
true,
),
("B5", "reality-opts:\n short-id: 1234e123\n more\n", false),
("B6", "reality-opts: {public-key: k, # c\n short-id: 1234e123}\n", true),
];
for (case, input, numeric) in cases {
let today = serde_yaml_ng::from_str::<Value>(input).expect("input should parse");
assert_eq!(has_numeric_short_id(&today), numeric, "{case}");
let (text, value) = short_id_tolerant(input, Path::new("b.yaml")).expect("input should parse");
assert!(matches!(text, Cow::Borrowed(_)), "{case}");
assert_eq!(value, today, "{case}");
}
}
#[test]
fn short_id_rewrite_keeps_every_numeric_short_id() {
let input = "\
proxies:
- name: a
reality-opts:
short-id: 1234e123
- {name: b, reality-opts: {short-id: 0e5}}
";
let (text, value) = short_id_tolerant(input, Path::new("b7.yaml")).expect("input should parse");
assert!(matches!(text, Cow::Owned(_)));
let short_ids: Vec<_> = value
.get("proxies")
.and_then(Value::as_sequence)
.expect("proxies should be a sequence")
.iter()
.map(|proxy| proxy.get("reality-opts").and_then(|opts| opts.get("short-id")).cloned())
.collect();
assert_eq!(
short_ids,
[
Some(Value::String("1234e123".into())),
Some(Value::String("0e5".into()))
]
);
}
}
+24 -54
View File
@@ -4,16 +4,14 @@ use serde_yaml_ng::{Mapping, Value};
use std::{ use std::{
io::Read as _, io::Read as _,
os::unix::process::CommandExt as _, os::unix::process::CommandExt as _,
path::{Path, PathBuf}, path::PathBuf,
process::{Command, Stdio}, process::{Command, Stdio},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use tokio::sync::Mutex; use tokio::sync::Mutex;
const DNS_STATE_FILE: &str = ".original_dns.txt";
const DNS_BACKUP_DIR: &str = ".original_dns";
// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind. // Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind.
const PLACEHOLDER_DNS: &str = "114.114.114.114"; const PLACEHOLDER_DNS: &str = "114.114.114.114\n223.5.5.5\n1.1.1.1";
// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit. // Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit.
pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -26,14 +24,6 @@ fn dns_state_dir() -> anyhow::Result<PathBuf> {
Ok(dir) Ok(dir)
} }
fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf {
if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() {
resource_dir.to_path_buf()
} else {
state_dir
}
}
// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed. // Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed.
fn needs_public_dns(config: &Mapping) -> bool { fn needs_public_dns(config: &Mapping) -> bool {
let section = |name: &str| config.get(name).and_then(Value::as_mapping); let section = |name: &str| config.get(name).and_then(Value::as_mapping);
@@ -79,25 +69,19 @@ pub async fn restore_public_dns() -> anyhow::Result<()> {
async fn set_public_dns() { async fn set_public_dns() {
logging!(debug, Type::Config, "try to set system dns"); logging!(debug, Type::Config, "try to set system dns");
let result = async { let result = run_dns_script("set_dns.sh", true).await;
let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh");
run_dns_script(&script, dns_state_dir()?).await
}
.await;
match result { match result {
Ok(()) => logging!(info, Type::Config, "set system dns successfully"), Ok(DnsScriptOutcome::Complete) => logging!(info, Type::Config, "set system dns successfully"),
Ok(DnsScriptOutcome::Deferred) => {
logging!(debug, Type::Config, "system DNS waits for a primary network service")
}
Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"), Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"),
} }
} }
async fn unset_public_dns() -> anyhow::Result<()> { async fn unset_public_dns() -> anyhow::Result<()> {
logging!(debug, Type::Config, "try to unset system dns"); logging!(debug, Type::Config, "try to unset system dns");
let result = async { let result = run_dns_script("unset_dns.sh", false).await.map(|_| ());
let resource_dir = crate::utils::dirs::app_resources_dir()?;
let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?);
run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await
}
.await;
match &result { match &result {
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"), Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"), Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
@@ -105,12 +89,21 @@ async fn unset_public_dns() -> anyhow::Result<()> {
result result
} }
enum DnsScriptOutcome {
Complete,
Deferred,
}
/// On timeout the script's whole process group is killed, so no networksetup it started writes late. /// On timeout the script's whole process group is killed, so no networksetup it started writes late.
async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> { async fn run_dns_script(script_name: &str, allow_deferred: bool) -> anyhow::Result<DnsScriptOutcome> {
let resource_dir = crate::utils::dirs::app_resources_dir()?;
let script = resource_dir.join(script_name);
let dir = dns_state_dir()?;
ensure!(script.exists(), "DNS script not found: {}", script.display()); ensure!(script.exists(), "DNS script not found: {}", script.display());
let mut child = Command::new("bash") let mut child = Command::new("bash")
.arg(script) .arg(&script)
.arg(PLACEHOLDER_DNS) .arg(PLACEHOLDER_DNS)
.arg(resource_dir)
.current_dir(dir) .current_dir(dir)
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@@ -134,41 +127,18 @@ async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> {
if let Some(mut pipe) = child.stderr.take() { if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)?; pipe.read_to_string(&mut stderr)?;
} }
// EX_TEMPFAIL is reserved by set_dns.sh for a network service that is not ready.
if allow_deferred && status.code() == Some(75) {
return Ok(DnsScriptOutcome::Deferred);
}
ensure!( ensure!(
status.success(), status.success(),
"exit code {}: {}", "exit code {}: {}",
status.code().unwrap_or(-1), status.code().unwrap_or(-1),
stderr.trim() stderr.trim()
); );
Ok(()) Ok(DnsScriptOutcome::Complete)
}) })
.await .await
.context("DNS script task failed")? .context("DNS script task failed")?
} }
#[cfg(test)]
mod tests {
#[test]
#[allow(clippy::expect_used)]
fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() {
use super::restore_dns_state_dir;
let root = std::env::temp_dir().join(format!("clash-verge-dns-{}", nanoid::nanoid!()));
let resource_dir = root.join("resources");
let state_dir = root.join("app-data");
std::fs::create_dir_all(&resource_dir).expect("create test resource directory");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir);
std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir);
// A migrated legacy backup whose restore failed must still be retried there.
std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state");
std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir);
std::fs::remove_dir_all(root).expect("remove test directory");
}
}
+2 -2
View File
@@ -33,9 +33,9 @@
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK",
"endpoints": [ "endpoints": [
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json",
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json", "https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json",
"https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json", "https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-proxy.json"
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json"
], ],
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
+2 -1
View File
@@ -26,8 +26,9 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" "https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
} }
+2 -1
View File
@@ -26,8 +26,9 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" "https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
} }
+2 -1
View File
@@ -26,8 +26,9 @@
"active": true, "active": true,
"dialog": false, "dialog": false,
"endpoints": [ "endpoints": [
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json",
"https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json", "https://update.hwdns.net/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json",
"https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2.json" "https://gh-proxy.org/https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update-fixed-webview2-proxy.json"
], ],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQyOEMyRjBCQkVGOUJEREYKUldUZnZmbStDeStNMHU5Mmo1N24xQXZwSVRYbXA2NUpzZE5oVzlqeS9Bc0t6RVV4MmtwVjBZaHgK"
} }
@@ -464,21 +464,6 @@ export const ConnectionTable = (props: Props) => {
] ]
}, [t]) }, [t])
useEffect(() => {
setColumnOrder((prevValue) => {
const baseFields = baseColumns.map((col) => col.field)
const prev = Array.isArray(prevValue) ? prevValue : []
const reconciled = reconcileColumnOrder(prev, baseFields)
if (
reconciled.length === prev.length &&
reconciled.every((field, i) => field === prev[i])
) {
return prevValue
}
return reconciled
})
}, [baseColumns, setColumnOrder])
const orderedColumns = useMemo(() => { const orderedColumns = useMemo(() => {
const baseFields = baseColumns.map((column) => column.field) const baseFields = baseColumns.map((column) => column.field)
const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields) const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields)
+37 -4
View File
@@ -1,6 +1,7 @@
import { DeveloperBoardOutlined } from '@mui/icons-material' import { DeveloperBoardOutlined, RestartAltRounded } from '@mui/icons-material'
import { Divider, Stack, Typography } from '@mui/material' import { Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material'
import { useMemo } from 'react' import { useLockFn } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useClash } from '@/hooks/use-clash' import { useClash } from '@/hooks/use-clash'
@@ -11,6 +12,8 @@ import {
useRulesData, useRulesData,
useSystemData, useSystemData,
} from '@/providers/app-data-context' } from '@/providers/app-data-context'
import { restartCore } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { EnhancedCard } from './enhanced-card' import { EnhancedCard } from './enhanced-card'
@@ -30,6 +33,23 @@ export const ClashInfoCard = () => {
const { rules } = useRulesData() const { rules } = useRulesData()
const uptime = useAppUptime() const uptime = useAppUptime()
const { systemProxyAddress } = useSystemData() const { systemProxyAddress } = useSystemData()
const [restarting, setRestarting] = useState(false)
const onRestart = useLockFn(async () => {
setRestarting(true)
try {
await mutate(() => restartCore(), {
id: 'restart-core',
successNotice: t(
'settings.feedback.notifications.clash.restartSuccess',
),
})
} catch {
// the funnel already notified
} finally {
setRestarting(false)
}
})
// 使用useMemo缓存格式化后的uptime,避免频繁计算 // 使用useMemo缓存格式化后的uptime,避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]) const formattedUptime = useMemo(() => formatUptime(uptime), [uptime])
@@ -101,7 +121,20 @@ export const ClashInfoCard = () => {
title={t('home.components.clashInfo.title')} title={t('home.components.clashInfo.title')}
icon={<DeveloperBoardOutlined />} icon={<DeveloperBoardOutlined />}
iconColor="warning" iconColor="warning"
action={null} action={
<Tooltip title={t('shared.actions.restart')}>
<span>
<IconButton
size="small"
aria-label={t('shared.actions.restart')}
loading={restarting}
onClick={onRestart}
>
<RestartAltRounded />
</IconButton>
</span>
</Tooltip>
}
> >
{cardContent} {cardContent}
</EnhancedCard> </EnhancedCard>
+64 -73
View File
@@ -1,4 +1,3 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import { import {
AccessTimeRounded, AccessTimeRounded,
ArrowDropDown, ArrowDropDown,
@@ -31,7 +30,15 @@ import {
useTheme, useTheme,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import {
useCallback,
useEffect,
useEffectEvent,
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'
@@ -570,20 +577,22 @@ export const CurrentProxyCard = () => {
return profileValue return profileValue
} }
if (profileKey !== baseKey) { return profileKey !== baseKey ? localStorage.getItem(baseKey) : null
const legacyValue = localStorage.getItem(baseKey)
if (legacyValue != null) {
localStorage.removeItem(baseKey)
localStorage.setItem(profileKey, legacyValue)
return legacyValue
}
}
return null
}, },
[getProfileStorageKey], [getProfileStorageKey],
) )
// Hand the pre-profile-scoping group key to the first profile that shows the card.
useEffect(() => {
const profileKey = getProfileStorageKey(STORAGE_KEY_GROUP)
if (profileKey === STORAGE_KEY_GROUP) return
if (localStorage.getItem(profileKey) != null) return
const legacyValue = localStorage.getItem(STORAGE_KEY_GROUP)
if (legacyValue == null) return
localStorage.removeItem(STORAGE_KEY_GROUP)
localStorage.setItem(profileKey, legacyValue)
}, [getProfileStorageKey])
const writeProfileScopedItem = useCallback( const writeProfileScopedItem = useCallback(
(baseKey: string, value: string) => { (baseKey: string, value: string) => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
@@ -615,25 +624,17 @@ export const CurrentProxyCard = () => {
return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 return savedSortType ? (Number(savedSortType) as ProxySortType) : 0
}) })
const [selectedGroupName, setSelectedGroupName] = useState('') const [lastGroupName, setLastGroupName] = useState('')
const [openSelect, setOpenSelect] = useState<OpenSelect>(null) const [openSelect, setOpenSelect] = useState<OpenSelect>(null)
const [openSelectMode, setOpenSelectMode] = useState(mode)
useEffect(() => { if (openSelectMode !== mode) {
setOpenSelectMode(mode)
setOpenSelect(null) setOpenSelect(null)
}, [mode]) }
const delayButtonRef = useRef<HTMLButtonElement>(null) const delayButtonRef = useRef<HTMLButtonElement>(null)
const delays = useGroupDelays(selectedGroupName || null)
const autoCheckInProgressRef = useRef(false) const autoCheckInProgressRef = useRef(false)
const latestTimeoutRef = useRef<number>(
verge?.default_latency_timeout || 10000,
)
const latestProxyMemberRef = useRef<ResolvedProxyMember | null>(null)
useEffect(() => {
latestTimeoutRef.current = verge?.default_latency_timeout || 10000
}, [verge?.default_latency_timeout])
const selectableGroups = useMemo(() => { const selectableGroups = useMemo(() => {
if (!proxyView) return [] if (!proxyView) return []
@@ -644,6 +645,39 @@ export const CurrentProxyCard = () => {
) )
}, [proxyView]) }, [proxyView])
const selectedGroupName = useMemo(() => {
if (isDirectMode) return 'DIRECT'
if (!proxyView) return lastGroupName
if (isGlobalMode) return proxyView.global?.name ?? 'GLOBAL'
const hasGroup = (name: string | null) =>
!!name && selectableGroups.some((group) => group.name === name)
if (hasGroup(lastGroupName)) return lastGroupName
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
if (hasGroup(savedGroup)) return savedGroup!
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
const primaryGroup =
selectableGroups.find((group) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) ?? selectableGroups[0]
return primaryGroup?.name ?? ''
}, [
isDirectMode,
isGlobalMode,
lastGroupName,
proxyView,
readProfileScopedItem,
selectableGroups,
])
// Keep the shown group, auto picks included, until it leaves the profile.
if (proxyView && lastGroupName !== selectedGroupName) {
setLastGroupName(selectedGroupName)
}
const delays = useGroupDelays(selectedGroupName || null)
const selectedGroup = useMemo<ProxyGroupView | null>(() => { const selectedGroup = useMemo<ProxyGroupView | null>(() => {
if (!proxyView || isDirectMode) return null if (!proxyView || isDirectMode) return null
if (isGlobalMode) return proxyView.global if (isGlobalMode) return proxyView.global
@@ -680,46 +714,6 @@ export const CurrentProxyCard = () => {
[optionsForGroup, selectedGroup], [optionsForGroup, selectedGroup],
) )
useEffect(() => {
if (!proxyView) return
if (isDirectMode) {
setSelectedGroupName('DIRECT')
return
}
if (isGlobalMode) {
setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL')
return
}
const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP)
const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择']
const primaryGroup =
selectableGroups.find((group) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) ?? selectableGroups[0]
const nextGroup = selectableGroups.some(
({ name }) => name === selectedGroupName,
)
? selectedGroupName
: selectableGroups.some(({ name }) => name === savedGroup)
? savedGroup!
: (primaryGroup?.name ?? '')
if (nextGroup !== selectedGroupName) {
setSelectedGroupName(nextGroup)
if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup)
}
}, [
isDirectMode,
isGlobalMode,
proxyView,
readProfileScopedItem,
selectableGroups,
selectedGroupName,
writeProfileScopedItem,
])
const currentOption = useMemo(() => { const currentOption = useMemo(() => {
if (!proxyView) return undefined if (!proxyView) return undefined
if (isDirectMode) { if (isDirectMode) {
@@ -743,12 +737,10 @@ export const CurrentProxyCard = () => {
: undefined : undefined
}, [isDirectMode, proxyView, selectedGroup]) }, [isDirectMode, proxyView, selectedGroup])
latestProxyMemberRef.current = currentOption?.member ?? null
const handleGroupChange = useCallback( const handleGroupChange = useCallback(
(newGroupName: string) => { (newGroupName: string) => {
if (isGlobalMode || isDirectMode) return if (isGlobalMode || isDirectMode) return
setSelectedGroupName(newGroupName) setLastGroupName(newGroupName)
writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName) writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName)
}, },
[isDirectMode, isGlobalMode, writeProfileScopedItem], [isDirectMode, isGlobalMode, writeProfileScopedItem],
@@ -793,7 +785,7 @@ export const CurrentProxyCard = () => {
color: 'text.secondary', color: 'text.secondary',
} }
const checkCurrentProxyDelay = useCallback(async () => { const checkCurrentProxyDelay = useEffectEvent(async () => {
if (autoCheckInProgressRef.current) return if (autoCheckInProgressRef.current) return
if (isDirectMode) return if (isDirectMode) return
@@ -802,7 +794,7 @@ export const CurrentProxyCard = () => {
if (!groupName || !proxyName) return if (!groupName || !proxyName) return
const proxyMember = latestProxyMemberRef.current const proxyMember = currentOption?.member
if (!proxyMember || !isInteractableMember(proxyMember)) { if (!proxyMember || !isInteractableMember(proxyMember)) {
debugLog( debugLog(
`[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`, `[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`,
@@ -812,7 +804,7 @@ export const CurrentProxyCard = () => {
autoCheckInProgressRef.current = true autoCheckInProgressRef.current = true
const timeout = latestTimeoutRef.current || 10000 const timeout = verge?.default_latency_timeout || 10000
try { try {
debugLog( debugLog(
@@ -828,7 +820,7 @@ export const CurrentProxyCard = () => {
autoCheckInProgressRef.current = false autoCheckInProgressRef.current = false
refreshProxy() refreshProxy()
} }
}, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName]) })
useEffect(() => { useEffect(() => {
if (isDirectMode) return if (isDirectMode) return
@@ -858,7 +850,6 @@ export const CurrentProxyCard = () => {
if (intervalTimer) clearTimeout(intervalTimer) if (intervalTimer) clearTimeout(intervalTimer)
} }
}, [ }, [
checkCurrentProxyDelay,
autoDelayIntervalMs, autoDelayIntervalMs,
isDirectMode, isDirectMode,
selectedGroupName, selectedGroupName,
@@ -4,6 +4,7 @@ import {
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
useEffectEvent,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useReducer, useReducer,
@@ -120,7 +121,6 @@ export const EnhancedCanvasTrafficGraph = memo(
const drawFrameRef = useRef<number | undefined>(undefined) const drawFrameRef = useRef<number | undefined>(undefined)
const hoverFrameRef = useRef<number | undefined>(undefined) const hoverFrameRef = useRef<number | undefined>(undefined)
const mouseMoveFrameRef = useRef<number | undefined>(undefined) const mouseMoveFrameRef = useRef<number | undefined>(undefined)
const scheduleDrawGraphRef = useRef<() => void>(() => {})
const pendingMousePositionRef = useRef<{ const pendingMousePositionRef = useRef<{
clientX: number clientX: number
clientY: number clientY: number
@@ -189,11 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo(
} }
}, [displayData]) }, [displayData])
const handleFocusStateChange = useCallback((focused: boolean) => { const handleFocusStateChange = useEffectEvent((focused: boolean) => {
isWindowFocusedRef.current = focused isWindowFocusedRef.current = focused
scheduleDrawGraphRef.current() scheduleDrawGraph()
}, []) })
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') { if (typeof window === 'undefined' || typeof document === 'undefined') {
@@ -217,7 +217,7 @@ export const EnhancedCanvasTrafficGraph = memo(
window.removeEventListener('blur', handleBlur) window.removeEventListener('blur', handleBlur)
document.removeEventListener('visibilitychange', handleVisibilityChange) document.removeEventListener('visibilitychange', handleVisibilityChange)
} }
}, [handleFocusStateChange]) }, [])
const calculateY = useCallback( const calculateY = useCallback(
( (
@@ -906,25 +906,21 @@ export const EnhancedCanvasTrafficGraph = memo(
scheduleDrawGraph() scheduleDrawGraph()
}, [scheduleDrawGraph]) }, [scheduleDrawGraph])
useEffect(() => { const redrawGraph = useEffectEvent(() => scheduleDrawGraph())
scheduleDrawGraphRef.current = scheduleDrawGraph
}, [scheduleDrawGraph])
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current const canvas = canvasRef.current
if (!canvas || typeof window === 'undefined') return if (!canvas || typeof window === 'undefined') return
if (typeof ResizeObserver === 'undefined') { if (typeof ResizeObserver === 'undefined') {
const handleResize = () => scheduleDrawGraphRef.current() const handleResize = () => redrawGraph()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
return () => { return () => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
} }
const resizeObserver = new ResizeObserver(() => const resizeObserver = new ResizeObserver(() => redrawGraph())
scheduleDrawGraphRef.current(),
)
resizeObserver.observe(canvas) resizeObserver.observe(canvas)
return () => { return () => {
+12 -7
View File
@@ -14,7 +14,7 @@ import {
useTheme, useTheme,
Fade, Fade,
} from '@mui/material' } from '@mui/material'
import { useState, useMemo, memo, FC } from 'react' import { useState, useMemo, useCallback, memo, FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ProxyControlSwitches from '@/components/shared/proxy-control-switches' import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
@@ -152,10 +152,15 @@ export const ProxyTunCard: FC = () => {
showNotice.error(err) showNotice.error(err)
} }
const handleTabChange = (tab: string) => { const handleSystemTabClick = useCallback(() => {
setActiveTab(tab) setActiveTab('system')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab) localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'system')
} }, [])
const handleTunTabClick = useCallback(() => {
setActiveTab('tun')
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, 'tun')
}, [])
const tabDescription = useMemo(() => { const tabDescription = useMemo(() => {
if (activeTab === 'system') { if (activeTab === 'system') {
@@ -191,14 +196,14 @@ export const ProxyTunCard: FC = () => {
> >
<TabButton <TabButton
isActive={activeTab === 'system'} isActive={activeTab === 'system'}
onClick={() => handleTabChange('system')} onClick={handleSystemTabClick}
icon={ComputerRounded} icon={ComputerRounded}
label={t('settings.sections.system.toggles.systemProxy')} label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyIndicator} hasIndicator={systemProxyIndicator}
/> />
<TabButton <TabButton
isActive={activeTab === 'tun'} isActive={activeTab === 'tun'}
onClick={() => handleTabChange('tun')} onClick={handleTunTabClick}
icon={TroubleshootRounded} icon={TroubleshootRounded}
label={t('settings.sections.system.toggles.tunMode')} label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode! && isTunModeAvailable} hasIndicator={enable_tun_mode! && isTunModeAvailable}
+18 -20
View File
@@ -8,7 +8,7 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material' import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
@@ -22,10 +22,23 @@ import {
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { getSystemInfo } from '@/services/cmds' import { getSystemInfo } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { version as appVersion } from '@root/package.json' import { version as appVersion } from '@root/package.json'
import { EnhancedCard } from './enhanced-card' import { EnhancedCard } from './enhanced-card'
const getOsInfo = async () => {
const info = await getSystemInfo()
const sysName = info.system_name
let sysVersion = info.system_version
if (sysName && sysVersion.toLowerCase().startsWith(sysName.toLowerCase())) {
sysVersion = sysVersion.substring(sysName.length).trim()
}
return `${sysName} ${sysVersion}`
}
export const SystemInfoCard = () => { export const SystemInfoCard = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { verge, patchVerge } = useVerge() const { verge, patchVerge } = useVerge()
@@ -36,31 +49,16 @@ export const SystemInfoCard = () => {
const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true) const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true)
const [osInfo, setOsInfo] = useState('') const { data: osInfo = '' } = useQuery({
queryKey: ['getSystemInfo'],
queryFn: getOsInfo,
})
const lastCheckUpdateText = useMemo( const lastCheckUpdateText = useMemo(
() => (lastCheckUpdate ? new Date(lastCheckUpdate).toLocaleString() : '-'), () => (lastCheckUpdate ? new Date(lastCheckUpdate).toLocaleString() : '-'),
[lastCheckUpdate], [lastCheckUpdate],
) )
useEffect(() => {
getSystemInfo()
.then((info) => {
const sysName = info.system_name
let sysVersion = info.system_version
if (
sysName &&
sysVersion.toLowerCase().startsWith(sysName.toLowerCase())
) {
sysVersion = sysVersion.substring(sysName.length).trim()
}
setOsInfo(`${sysName} ${sysVersion}`)
})
.catch(console.error)
}, [])
useEffect(() => { useEffect(() => {
if (!verge?.auto_check_update) return if (!verge?.auto_check_update) return
if (readLastCheckTime() !== null) return if (readLastCheckTime() !== null) return
+5 -7
View File
@@ -21,6 +21,7 @@ import {
memo, memo,
useCallback, useCallback,
useEffect, useEffect,
useEffectEvent,
useReducer, useReducer,
useRef, useRef,
useState, useState,
@@ -113,7 +114,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const setProfileLoading = useSetProfileLoading() const setProfileLoading = useSetProfileLoading()
const [showNextUpdate, setShowNextUpdate] = useState(false) const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
const [nextUpdateTime, setNextUpdateTime] = useState('') const [nextUpdateTime, setNextUpdateTime] = useState('')
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>( const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined, undefined,
@@ -203,10 +203,6 @@ const ProfileItemBase = (props: ProfileItemProps) => {
setShowNextUpdate(!showNextUpdate) setShowNextUpdate(!showNextUpdate)
} }
useEffect(() => {
showNextUpdateRef.current = showNextUpdate
}, [showNextUpdate])
useEffect(() => { useEffect(() => {
if (showNextUpdate) { if (showNextUpdate) {
fetchNextUpdateTime() fetchNextUpdateTime()
@@ -218,8 +214,10 @@ const ProfileItemBase = (props: ProfileItemProps) => {
updated, updated,
]) ])
const isNextUpdateShown = useEffectEvent(() => showNextUpdate)
useEffect(() => { useEffect(() => {
if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return if (timerUpdateRevision === 0 || !isNextUpdateShown()) return
if (refreshTimeoutRef.current !== undefined) { if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current)
@@ -236,7 +234,7 @@ const ProfileItemBase = (props: ProfileItemProps) => {
}, [fetchNextUpdateTime, timerUpdateRevision]) }, [fetchNextUpdateTime, timerUpdateRevision])
useEffect(() => { useEffect(() => {
if (completedUpdateRevision === 0 || !showNextUpdateRef.current) return if (completedUpdateRevision === 0 || !isNextUpdateShown()) return
fetchNextUpdateTime() fetchNextUpdateTime()
}, [completedUpdateRevision, fetchNextUpdateTime]) }, [completedUpdateRevision, fetchNextUpdateTime])
+19 -14
View File
@@ -10,7 +10,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useEffect, useImperativeHandle, useRef, useState } from 'react' import { useImperativeHandle, useRef, useState } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -77,17 +77,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
}, },
})) }))
const selfProxy = watch('option.self_proxy')
const withProxy = watch('option.with_proxy')
useEffect(() => {
if (selfProxy) setValue('option.with_proxy', false)
}, [selfProxy, setValue])
useEffect(() => {
if (withProxy) setValue('option.self_proxy', false)
}, [setValue, withProxy])
const handleOk = useLockFn( const handleOk = useLockFn(
handleSubmit(async (form) => { handleSubmit(async (form) => {
setLoading(true) setLoading(true)
@@ -419,7 +408,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel> <InputLabel>
{t('profiles.modals.profileForm.fields.useSystemProxy')} {t('profiles.modals.profileForm.fields.useSystemProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value!} {...field} color="primary" /> <Switch
checked={field.value!}
{...field}
onChange={(_, checked) => {
field.onChange(checked)
if (checked) setValue('option.self_proxy', false)
}}
color="primary"
/>
</StyledBox> </StyledBox>
)} )}
/> />
@@ -432,7 +429,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<InputLabel> <InputLabel>
{t('profiles.modals.profileForm.fields.useClashProxy')} {t('profiles.modals.profileForm.fields.useClashProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value!} {...field} color="primary" /> <Switch
checked={field.value!}
{...field}
onChange={(_, checked) => {
field.onChange(checked)
if (checked) setValue('option.with_proxy', false)
}}
color="primary"
/>
</StyledBox> </StyledBox>
)} )}
/> />
+30
View File
@@ -1,3 +1,5 @@
import * as yaml from 'js-yaml'
import { import {
getRecord, getRecord,
rebindNode, rebindNode,
@@ -34,3 +36,31 @@ export const rebindProxyChainItems = (
delay: record?.history.at(-1)?.delay, delay: record?.history.at(-1)?.delay,
} }
}) })
interface ParsedChainConfig {
proxies?: Array<{
name: string
type: string
[key: string]: any
}>
}
export const parseChainConfigItems = (configData: string): ProxyChainItem[] => {
try {
// JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(configData) as ParsedChainConfig | null
const timestamp = Date.now()
return (
parsedConfig?.proxies?.map((proxy, index) => ({
id: `${proxy.name}_${timestamp}_${index}`,
name: proxy.name,
type: proxy.type,
delay: undefined,
})) || []
)
} catch (error) {
console.error('Failed to process chain config data:', error)
return []
}
}
-43
View File
@@ -25,7 +25,6 @@ import {
keyframes, keyframes,
useTheme, useTheme,
} from '@mui/material' } from '@mui/material'
import * as yaml from 'js-yaml'
import { import {
type Ref, type Ref,
useCallback, useCallback,
@@ -60,18 +59,9 @@ const chainPointerSensor = PointerSensor.configure({
type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown } type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown }
interface ParsedChainConfig {
proxies?: Array<{
name: string
type: string
[key: string]: any
}>
}
interface ProxyChainProps { interface ProxyChainProps {
proxyChain: ProxyChainItem[] proxyChain: ProxyChainItem[]
onUpdateChain: (chain: ProxyChainItem[]) => void onUpdateChain: (chain: ProxyChainItem[]) => void
chainConfigData?: string | null
onMarkUnsavedChanges?: () => void onMarkUnsavedChanges?: () => void
mode?: string mode?: string
selectedGroup?: string | null selectedGroup?: string | null
@@ -98,21 +88,6 @@ const arrowFadeIn = keyframes`
} }
` `
const toChainItems = (
parsedConfig: ParsedChainConfig | null | undefined,
): ProxyChainItem[] => {
const timestamp = Date.now()
return (
parsedConfig?.proxies?.map((proxy, index) => ({
id: `${proxy.name}_${timestamp}_${index}`,
name: proxy.name,
type: proxy.type,
delay: undefined,
})) || []
)
}
interface ChainCardProps { interface ChainCardProps {
proxy: ProxyChainItem proxy: ProxyChainItem
index: number index: number
@@ -328,7 +303,6 @@ const SortableProxyChainItem = ({
export const ProxyChain = ({ export const ProxyChain = ({
proxyChain, proxyChain,
onUpdateChain, onUpdateChain,
chainConfigData,
onMarkUnsavedChanges, onMarkUnsavedChanges,
mode, mode,
selectedGroup, selectedGroup,
@@ -580,23 +554,6 @@ export const ProxyChain = ({
recordSelection, recordSelection,
]) ])
// 处理链式代理配置数据
useEffect(() => {
if (chainConfigData) {
try {
// JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig
const chainItems = toChainItems(parsedConfig)
if (chainItems.length > 0) {
onUpdateChain(chainItems)
}
} catch (error) {
console.error('Failed to process chain config data:', error)
}
}
}, [chainConfigData, onUpdateChain])
return ( return (
<Paper <Paper
elevation={1} elevation={1}
+12 -3
View File
@@ -76,7 +76,7 @@ interface GroupSelectMenuProps {
interface ProxyGroupsChainProps { interface ProxyGroupsChainProps {
mode: string mode: string
chainConfigData?: string | null chainConfigItems?: ProxyChainItem[] | null
onExitChainMode: () => void onExitChainMode: () => void
availableGroups: any[] availableGroups: any[]
activeSelectedGroup: string | null activeSelectedGroup: string | null
@@ -305,7 +305,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
mode, mode,
chainConfigData, chainConfigItems,
onExitChainMode, onExitChainMode,
availableGroups, availableGroups,
activeSelectedGroup, activeSelectedGroup,
@@ -338,6 +338,16 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
return [] return []
}) })
// Each fetched runtime chain replaces the stored one once.
const configItems = chainConfigItems ?? null
const [appliedConfigItems, setAppliedConfigItems] = useState<
ProxyChainItem[] | null
>(null)
if (configItems !== appliedConfigItems) {
setAppliedConfigItems(configItems)
if (configItems?.length) setProxyChain(configItems)
}
const candidateNodes = useMemo( const candidateNodes = useMemo(
() => () =>
renderList.flatMap((item) => { renderList.flatMap((item) => {
@@ -520,7 +530,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
<ProxyChain <ProxyChain
proxyChain={currentProxyChain} proxyChain={currentProxyChain}
onUpdateChain={setProxyChain} onUpdateChain={setProxyChain}
chainConfigData={chainConfigData}
mode={mode} mode={mode}
selectedGroup={activeSelectedGroup} selectedGroup={activeSelectedGroup}
onExitChainMode={onExitChainMode} onExitChainMode={onExitChainMode}
+34 -8
View File
@@ -32,6 +32,7 @@ import {
} from '@/types/proxy-view' } from '@/types/proxy-view'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import type { ProxyChainItem } from './proxy-chain-model'
import { ProxyEmptyState } from './proxy-empty-state' import { ProxyEmptyState } from './proxy-empty-state'
import { import {
resolveEmptyListReason, resolveEmptyListReason,
@@ -68,7 +69,7 @@ function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
interface Props { interface Props {
mode: string mode: string
isChainMode?: boolean isChainMode?: boolean
chainConfigData?: string | null chainConfigItems?: ProxyChainItem[] | null
onExitChainMode: () => void onExitChainMode: () => void
} }
@@ -183,6 +184,7 @@ function useProxyRenderState(
renderList, renderList,
onProxies, onProxies,
onHeadState, onHeadState,
setHeadState: setListHeadState,
handleCheckAll, handleCheckAll,
saveScrollPosition, saveScrollPosition,
getScrollPosition, getScrollPosition,
@@ -191,10 +193,10 @@ function useProxyRenderState(
function ChainProxyGroups(props: { function ChainProxyGroups(props: {
mode: string mode: string
chainConfigData?: string | null chainConfigItems?: ProxyChainItem[] | null
onExitChainMode: () => void onExitChainMode: () => void
}) { }) {
const { mode, chainConfigData, onExitChainMode } = props const { mode, chainConfigItems, onExitChainMode } = props
const { proxyView } = useProxiesData() const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null) const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@@ -348,7 +350,7 @@ function ChainProxyGroups(props: {
<Suspense fallback={<BaseLoading />}> <Suspense fallback={<BaseLoading />}>
<ProxyGroupsChain <ProxyGroupsChain
mode={mode} mode={mode}
chainConfigData={chainConfigData} chainConfigItems={chainConfigItems}
onExitChainMode={onExitChainMode} onExitChainMode={onExitChainMode}
availableGroups={availableGroups} availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup} activeSelectedGroup={activeSelectedGroup}
@@ -377,12 +379,17 @@ function NormalProxyGroups(props: { mode: string }) {
renderList, renderList,
onProxies, onProxies,
onHeadState, onHeadState,
setHeadState,
handleCheckAll, handleCheckAll,
getScrollPosition, getScrollPosition,
saveScrollPosition, saveScrollPosition,
} = useProxyRenderState(mode, false, null) } = useProxyRenderState(mode, false, null)
const emptyList = useEmptyRenderList() const emptyList = useEmptyRenderList()
const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout() const { onDragEnd: onHeaderDragEnd } = useProxyGroupHeaderLayout()
const { proxyView } = useProxiesData()
const globalGroupName = proxyView?.global?.name
// The revealed group sits below GLOBAL's rows, so a click must bring it into view.
const pendingRevealRef = useRef<string | null>(null)
const renderFirstRef = useRef(true) const renderFirstRef = useRef(true)
// Do not persist intermediate positions produced while restoring virtual scroll. // Do not persist intermediate positions produced while restoring virtual scroll.
const isRestoringRef = useRef(false) const isRestoringRef = useRef(false)
@@ -477,9 +484,17 @@ function NormalProxyGroups(props: { mode: string }) {
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
if (!isInteractableMember(member)) return if (!isInteractableMember(member)) return
if (group.name === globalGroupName) {
pendingRevealRef.current =
member.kind === 'group' ? member.ref.name : null
// The selection refetches the view, so skip onHeadState's refetch.
if (member.kind === 'group') {
setHeadState(member.ref.name, { open: true })
}
}
select(group.name, member.ref.name, group.now!, group.fixed!) select(group.name, member.ref.name, group.now!, group.fixed!)
}, },
[select], [globalGroupName, select, setHeadState],
) )
const handleLocation = useStableCallback((group: ProxyGroupView) => { const handleLocation = useStableCallback((group: ProxyGroupView) => {
@@ -525,6 +540,17 @@ function NormalProxyGroups(props: { mode: string }) {
return Array.from(new Set(names)) return Array.from(new Set(names))
}, [renderList]) }, [renderList])
useEffect(() => {
if (mode !== 'global') {
pendingRevealRef.current = null
return
}
const groupName = pendingRevealRef.current
if (!groupName || !proxyGroupNames.includes(groupName)) return
pendingRevealRef.current = null
handleGroupLocationByName(groupName)
}, [handleGroupLocationByName, mode, proxyGroupNames])
const handleGroupToggle = useCallback( const handleGroupToggle = useCallback(
async (group: ProxyGroupView) => { async (group: ProxyGroupView) => {
const index = renderList.findIndex( const index = renderList.findIndex(
@@ -608,7 +634,7 @@ function NormalProxyGroups(props: { mode: string }) {
/> />
</DragDropProvider> </DragDropProvider>
{(mode === 'rule' || mode === 'global') && ( {mode === 'rule' && (
<ProxyGroupNavigator <ProxyGroupNavigator
proxyGroupNames={proxyGroupNames} proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName} onGroupLocation={handleGroupLocationByName}
@@ -621,7 +647,7 @@ function NormalProxyGroups(props: { mode: string }) {
} }
export const ProxyGroups = (props: Props) => { export const ProxyGroups = (props: Props) => {
const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props const { mode, isChainMode = false, chainConfigItems, onExitChainMode } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles() const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData() const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData() const { isRunningModePending } = useSystemData()
@@ -645,7 +671,7 @@ export const ProxyGroups = (props: Props) => {
return isChainMode ? ( return isChainMode ? (
<ChainProxyGroups <ChainProxyGroups
mode={mode} mode={mode}
chainConfigData={chainConfigData} chainConfigItems={chainConfigItems}
onExitChainMode={onExitChainMode} onExitChainMode={onExitChainMode}
/> />
) : ( ) : (
+19 -18
View File
@@ -6,6 +6,7 @@ import { useVerge } from '@/hooks/use-verge'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import delayManager, { type DelaySnapshot } from '@/services/delay' import delayManager, { type DelaySnapshot } from '@/services/delay'
import { import {
findCurrentGroupMember,
isInteractableMember, isInteractableMember,
resolveMember, resolveMember,
selectGlobalChainNodes, selectGlobalChainNodes,
@@ -209,8 +210,10 @@ export const useRenderList = (
const renderGroups = useMemo(() => { const renderGroups = useMemo(() => {
if (!proxyView) return [] if (!proxyView) return []
if (mode === 'rule') return proxyView.groups if (mode === 'rule') return proxyView.groups
const global = proxyView.global ? [proxyView.global] : [] const { global } = proxyView
return mode === 'global' ? [...global, ...proxyView.groups] : global if (!global) return []
const current = findCurrentGroupMember(proxyView, global)?.member
return current?.kind === 'group' ? [global, current.group] : [global]
}, [mode, proxyView]) }, [mode, proxyView])
// Every group this list draws, so a test settling in any of them re-sorts that group. // Every group this list draws, so a test settling in any of them re-sorts that group.
@@ -260,10 +263,10 @@ export const useRenderList = (
})) }))
} }
const useRule = mode === 'rule' || mode === 'global'
const cache = groupCacheRef.current const cache = groupCacheRef.current
const retList = renderGroups.flatMap((group) => { const retList = renderGroups.flatMap((group) => {
const isFlat = group === proxyView.global
const headState = headStates[group.name] || DEFAULT_STATE const headState = headStates[group.name] || DEFAULT_STATE
const cached = cache.get(group.name) const cached = cache.get(group.name)
if ( if (
@@ -279,17 +282,19 @@ export const useRenderList = (
} }
const ret: IRenderItem[] = [ const ret: IRenderItem[] = [
{ isFlat
type: 0, ? { type: 1, key: `head-${group.name}`, group, headState }
key: group.name, : {
group, type: 0,
headState, key: group.name,
icon: group.icon!, group,
testUrl: group.testUrl!, headState,
}, icon: group.icon!,
testUrl: group.testUrl!,
},
] ]
if (headState.open || !useRule) { if (headState.open || isFlat) {
const occurrences = filterSort( const occurrences = filterSort(
resolveOccurrences(proxyView, group), resolveOccurrences(proxyView, group),
group.name, group.name,
@@ -302,9 +307,6 @@ export const useRenderList = (
useRegularExpression: headState.filterUseRegularExpression, useRegularExpression: headState.filterUseRegularExpression,
}, },
) )
if (!useRule) {
ret.push({ type: 1, key: `head-${group.name}`, group, headState })
}
if (occurrences.length === 0) { if (occurrences.length === 0) {
ret.push({ type: 3, key: `empty-${group.name}`, group, headState }) ret.push({ type: 3, key: `empty-${group.name}`, group, headState })
} else if (col > 1) { } else if (col > 1) {
@@ -343,9 +345,8 @@ export const useRenderList = (
return ret return ret
}) })
const filtered = !useRule const filtered =
? retList.slice(1) mode === 'rule' ? retList.filter((item) => !item.group.hidden) : retList
: retList.filter((item) => !item.group.hidden)
if ( if (
prevListRef.current.length === filtered.length && prevListRef.current.length === filtered.length &&
filtered.every((item, index) => item === prevListRef.current[index]) filtered.every((item, index) => item === prevListRef.current[index])
@@ -133,7 +133,11 @@ export function AutoBackupSettings() {
return ( return (
<Fragment> <Fragment>
<ListItem divider disableGutters> <ListItem
data-setting-target="settings.modals.backup.auto.scheduleLabel"
divider
disableGutters
>
<Stack <Stack
direction="row" direction="row"
spacing={1} spacing={1}
@@ -152,7 +156,11 @@ export function AutoBackupSettings() {
</Stack> </Stack>
</ListItem> </ListItem>
<ListItem divider disableGutters> <ListItem
data-setting-target="settings.modals.backup.auto.intervalLabel"
divider
disableGutters
>
<Stack <Stack
direction="row" direction="row"
spacing={2} spacing={2}
@@ -194,7 +202,11 @@ export function AutoBackupSettings() {
</Stack> </Stack>
</ListItem> </ListItem>
<ListItem divider disableGutters> <ListItem
data-setting-target="settings.modals.backup.auto.changeLabel"
divider
disableGutters
>
<Stack <Stack
direction="row" direction="row"
spacing={1} spacing={1}
@@ -173,6 +173,7 @@ export const BackupConfigViewer = memo(
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
data-setting-target="settings.modals.backup.fields.webdavUrl"
fullWidth fullWidth
label={t('settings.modals.backup.fields.webdavUrl')} label={t('settings.modals.backup.fields.webdavUrl')}
variant="outlined" variant="outlined"
@@ -187,6 +188,7 @@ export const BackupConfigViewer = memo(
</Grid> </Grid>
<Grid size={{ xs: 6 }}> <Grid size={{ xs: 6 }}>
<TextField <TextField
data-setting-target="settings.modals.backup.fields.username"
label={t('settings.modals.backup.fields.username')} label={t('settings.modals.backup.fields.username')}
variant="outlined" variant="outlined"
size="small" size="small"
@@ -199,6 +201,7 @@ export const BackupConfigViewer = memo(
</Grid> </Grid>
<Grid size={{ xs: 6 }}> <Grid size={{ xs: 6 }}>
<TextField <TextField
data-setting-target="webdav-password"
label={t('shared.labels.password')} label={t('shared.labels.password')}
type="password" type="password"
variant="outlined" variant="outlined"
@@ -230,6 +233,7 @@ export const BackupConfigViewer = memo(
color={'primary'} color={'primary'}
sx={{ height: '100%' }} sx={{ height: '100%' }}
type="button" type="button"
data-setting-target="webdav-save"
onClick={handleSubmit(save)} onClick={handleSubmit(save)}
> >
{t('shared.actions.save')} {t('shared.actions.save')}
@@ -239,6 +243,7 @@ export const BackupConfigViewer = memo(
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
data-setting-target="webdav-backup"
onClick={handleBackup} onClick={handleBackup}
type="button" type="button"
size="large" size="large"
@@ -247,6 +252,7 @@ export const BackupConfigViewer = memo(
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
data-setting-target="webdav-refresh"
onClick={onRefresh} onClick={onRefresh}
type="button" type="button"
size="large" size="large"
@@ -146,7 +146,12 @@ export const BackupHistoryViewer = ({
setRows([]) setRows([])
return return
} }
if (!isLocal && webdavStatus === 'failed' && !options?.force) { // Read the status here: as a dependency, the write below re-runs the fetch.
if (
!isLocal &&
getWebdavStatus(webdavSignature) === 'failed' &&
!options?.force
) {
setRows([]) setRows([])
return return
} }
@@ -180,7 +185,7 @@ export const BackupHistoryViewer = ({
setLoading(false) setLoading(false)
} }
}, },
[buildRow, isLocal, open, shouldSkipWebDav, webdavSignature, webdavStatus], [buildRow, isLocal, open, shouldSkipWebDav, webdavSignature],
) )
useEffect(() => { useEffect(() => {
@@ -322,7 +327,13 @@ export const BackupHistoryViewer = ({
onCancel={onClose} onCancel={onClose}
onClose={onClose} onClose={onClose}
> >
<Box sx={{ position: 'relative', minHeight: 320 }}> <Box
data-settings-dialog="backup-history"
data-search-ready={!loading}
data-setting-target="backup-history"
tabIndex={-1}
sx={{ position: 'relative', minHeight: 320 }}
>
<BaseLoadingOverlay isLoading={isBusy} /> <BaseLoadingOverlay isLoading={isBusy} />
<Stack spacing={2}> <Stack spacing={2}>
<Stack <Stack
@@ -407,6 +418,7 @@ export const BackupHistoryViewer = ({
<IconButton <IconButton
size="small" size="small"
disabled={isBusy} disabled={isBusy}
data-setting-target="backup-history-export"
onClick={() => handleExport(row.filename)} onClick={() => handleExport(row.filename)}
> >
<DownloadRounded fontSize="small" /> <DownloadRounded fontSize="small" />
@@ -415,6 +427,7 @@ export const BackupHistoryViewer = ({
<IconButton <IconButton
size="small" size="small"
disabled={isBusy} disabled={isBusy}
data-setting-target="backup-history-delete"
onClick={() => handleDelete(row.filename)} onClick={() => handleDelete(row.filename)}
> >
<DeleteOutlined fontSize="small" /> <DeleteOutlined fontSize="small" />
@@ -422,6 +435,7 @@ export const BackupHistoryViewer = ({
<IconButton <IconButton
size="small" size="small"
disabled={isBusy} disabled={isBusy}
data-setting-target="backup-history-restore"
onClick={() => handleRestore(row.filename)} onClick={() => handleRestore(row.filename)}
> >
<RestoreRounded fontSize="small" /> <RestoreRounded fontSize="small" />
+36 -4
View File
@@ -9,7 +9,7 @@ import {
import { open as openDialog } from '@tauri-apps/plugin-dialog' import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { ReactNode, Ref } from 'react' import type { ReactNode, Ref } from 'react'
import { useCallback, useImperativeHandle, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef } from '@/components/base' import { BaseDialog, DialogRef } from '@/components/base'
@@ -23,6 +23,8 @@ import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service' import { errorDetail, showNotice } from '@/services/notice-service'
import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status' import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status'
import { useSettingDialog } from '../settings-search-context'
import { AutoBackupSettings } from './auto-backup-settings' import { AutoBackupSettings } from './auto-backup-settings'
import { BackupHistoryViewer } from './backup-history-viewer' import { BackupHistoryViewer } from './backup-history-viewer'
import { BackupWebdavDialog } from './backup-webdav-dialog' import { BackupWebdavDialog } from './backup-webdav-dialog'
@@ -39,9 +41,18 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [historySource, setHistorySource] = useState<BackupSource>('local') const [historySource, setHistorySource] = useState<BackupSource>('local')
const [historyPage, setHistoryPage] = useState(0) const [historyPage, setHistoryPage] = useState(0)
const [webdavDialogOpen, setWebdavDialogOpen] = useState(false) const [webdavDialogOpen, setWebdavDialogOpen] = useState(false)
const searchParentRef = useRef<'webdav' | 'history' | null>(null)
const closeSearchDetail = (detail: 'webdav' | 'history') => {
if (detail === 'webdav') setWebdavDialogOpen(false)
else setHistoryOpen(false)
if (searchParentRef.current === detail) {
searchParentRef.current = null
setOpen(false)
}
}
const webdavSignature = buildWebdavSignature(verge) const webdavSignature = buildWebdavSignature(verge)
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'backup-viewer', open, () => ({
open: () => setOpen(true), open: () => setOpen(true),
close: () => setOpen(false), close: () => setOpen(false),
})) }))
@@ -52,6 +63,23 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
setHistoryOpen(true) setHistoryOpen(true)
} }
useSettingDialog(undefined, 'backup-webdav', webdavDialogOpen, () => ({
open: () => {
if (!open) searchParentRef.current = 'webdav'
setOpen(true)
setWebdavDialogOpen(true)
},
close: () => setWebdavDialogOpen(false),
}))
useSettingDialog(undefined, 'backup-history', historyOpen, () => ({
open: () => {
if (!open) searchParentRef.current = 'history'
setOpen(true)
openHistory('local')
},
close: () => setHistoryOpen(false),
}))
const handleBackup = useLockFn(async (target: BackupSource) => { const handleBackup = useLockFn(async (target: BackupSource) => {
try { try {
setBusyAction(target) setBusyAction(target)
@@ -129,6 +157,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
> >
<span data-settings-dialog="backup-viewer" />
<Stack spacing={2}> <Stack spacing={2}>
<Stack <Stack
spacing={1} spacing={1}
@@ -166,6 +195,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
description: t('settings.modals.backup.manual.local'), description: t('settings.modals.backup.manual.local'),
actions: [ actions: [
<Button <Button
data-setting-target="settings.modals.backup.actions.backup"
key="backup" key="backup"
variant="contained" variant="contained"
size="small" size="small"
@@ -176,6 +206,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.backup.actions.backup')} {t('settings.modals.backup.actions.backup')}
</Button>, </Button>,
<Button <Button
data-setting-target="settings.modals.backup.actions.viewHistory"
key="history" key="history"
variant="outlined" variant="outlined"
size="small" size="small"
@@ -185,6 +216,7 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.backup.actions.viewHistory')} {t('settings.modals.backup.actions.viewHistory')}
</Button>, </Button>,
<Button <Button
data-setting-target="settings.modals.backup.actions.importBackup"
key="import" key="import"
variant="text" variant="text"
size="small" size="small"
@@ -263,11 +295,11 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
page={historyPage} page={historyPage}
onSourceChange={setHistorySource} onSourceChange={setHistorySource}
onPageChange={setHistoryPage} onPageChange={setHistoryPage}
onClose={() => setHistoryOpen(false)} onClose={() => closeSearchDetail('history')}
/> />
<BackupWebdavDialog <BackupWebdavDialog
open={webdavDialogOpen} open={webdavDialogOpen}
onClose={() => setWebdavDialogOpen(false)} onClose={() => closeSearchDetail('webdav')}
onBackupSuccess={() => openHistory('webdav')} onBackupSuccess={() => openHistory('webdav')}
setBusy={setWebdavBusy} setBusy={setWebdavBusy}
/> />
@@ -76,7 +76,13 @@ export const BackupWebdavDialog = ({
onCancel={onClose} onCancel={onClose}
onClose={onClose} onClose={onClose}
> >
<Box sx={{ position: 'relative' }}> <Box
data-settings-dialog="backup-webdav"
data-search-ready={!loading}
data-setting-target="webdav-state"
tabIndex={-1}
sx={{ position: 'relative' }}
>
<BaseLoadingOverlay isLoading={loading} /> <BaseLoadingOverlay isLoading={loading} />
<BackupConfigViewer <BackupConfigViewer
setLoading={handleLoading} setLoading={handleLoading}
@@ -13,7 +13,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { closeAllConnections } from 'tauri-plugin-mihomo-api'
@@ -24,6 +24,8 @@ import { changeClashCore, restartCore, upgradeClashCore } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
const VALID_CORE = [ const VALID_CORE = [
{ {
name: 'Mihomo', name: 'Mihomo',
@@ -49,7 +51,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [restarting, setRestarting] = useState(false) const [restarting, setRestarting] = useState(false)
const [changingCore, setChangingCore] = useState<string | null>(null) const [changingCore, setChangingCore] = useState<string | null>(null)
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'clash-core-viewer', open, () => ({
open: () => setOpen(true), open: () => setOpen(true),
close: () => setOpen(false), close: () => setOpen(false),
})) }))
@@ -139,6 +141,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
loading={upgrading} loading={upgrading}
disabled={restarting || changingCore !== null} disabled={restarting || changingCore !== null}
sx={{ marginRight: '8px' }} sx={{ marginRight: '8px' }}
data-setting-target="core-upgrade"
onClick={onUpgrade} onClick={onUpgrade}
> >
{t('shared.actions.upgrade')} {t('shared.actions.upgrade')}
@@ -150,6 +153,7 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
loadingPosition="start" loadingPosition="start"
loading={restarting} loading={restarting}
disabled={upgrading} disabled={upgrading}
data-setting-target="core-restart"
onClick={onRestart} onClick={onRestart}
> >
{t('shared.actions.restart')} {t('shared.actions.restart')}
@@ -170,7 +174,12 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
> >
<List component="nav"> <List
component="nav"
data-settings-dialog="clash-core-viewer"
data-setting-target="core-variants"
tabIndex={-1}
>
{VALID_CORE.map((each) => ( {VALID_CORE.map((each) => (
<ListItemButton <ListItemButton
key={each.core} key={each.core}
@@ -9,7 +9,7 @@ import {
TextField, TextField,
} from '@mui/material' } from '@mui/material'
import { useLockFn, useRequest } from 'ahooks' import { useLockFn, useRequest } from 'ahooks'
import { forwardRef, useImperativeHandle, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from '@/components/base' import { BaseDialog, Switch } from '@/components/base'
@@ -22,6 +22,8 @@ import { showNotice } from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys' import type { TranslationKey } from '@/types/generated/i18n-keys'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { useSettingDialog } from '../settings-search-context'
const OS = getSystem() const OS = getSystem()
interface ClashPortViewerRef { interface ClashPortViewerRef {
@@ -87,6 +89,7 @@ const readPortSettings = (
}) })
interface PortRowProps { interface PortRowProps {
searchTarget: string
label: string label: string
secondary?: string secondary?: string
port: number port: number
@@ -97,6 +100,7 @@ interface PortRowProps {
} }
const PortRow = ({ const PortRow = ({
searchTarget,
label, label,
secondary, secondary,
port, port,
@@ -107,7 +111,10 @@ const PortRow = ({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}> <ListItem
data-setting-target={searchTarget}
sx={{ padding: '4px 0', minHeight: 36 }}
>
<ListItemText <ListItemText
primary={label} primary={label}
secondary={secondary} secondary={secondary}
@@ -129,6 +136,7 @@ const PortRow = ({
/> />
<IconButton <IconButton
size="small" size="small"
data-setting-target="port-random"
onClick={() => onPortChange(generateRandomPort())} onClick={() => onPortChange(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')} title={t('settings.modals.clashPort.actions.random')}
disabled={!enabled} disabled={!enabled}
@@ -192,7 +200,7 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
}, },
) )
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'clash-port-viewer', open, () => ({
open: () => { open: () => {
setPorts(readPortSettings(verge, configuredMixedPort)) setPorts(readPortSettings(verge, configuredMixedPort))
setOpen(true) setOpen(true)
@@ -245,9 +253,11 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="clash-port-viewer" />
<List sx={{ width: '100%' }}> <List sx={{ width: '100%' }}>
<PortRow <PortRow
label={t('settings.modals.clashPort.fields.mixed')} label={t('settings.modals.clashPort.fields.mixed')}
searchTarget="settings.modals.clashPort.fields.mixed"
secondary={ secondary={
displayedMixedPort !== configuredMixedPort displayedMixedPort !== configuredMixedPort
? t('settings.modals.clashPort.messages.runningPort', { ? t('settings.modals.clashPort.messages.runningPort', {
@@ -265,6 +275,7 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
<PortRow <PortRow
key={key} key={key}
label={t(label)} label={t(label)}
searchTarget={label}
port={ports[key].port} port={ports[key].port}
enabled={ports[key].enabled} enabled={ports[key].enabled}
onPortChange={(port) => updateListener(key, { port })} onPortChange={(port) => updateListener(key, { port })}
@@ -12,7 +12,7 @@ import {
Tooltip, Tooltip,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { useImperativeHandle, useState, type Ref } from 'react' import { useState, type Ref } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch } from '@/components/base' import { BaseDialog, DialogRef, Switch } from '@/components/base'
@@ -20,6 +20,8 @@ import { useClashInfo } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) { export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -35,7 +37,7 @@ export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) {
) )
// 对话框打开时初始化配置 // 对话框打开时初始化配置
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'controller-viewer', open, () => ({
open: async () => { open: async () => {
setOpen(true) setOpen(true)
setController(clashInfo?.server || '') setController(clashInfo?.server || '')
@@ -124,8 +126,10 @@ export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="controller-viewer" />
<List> <List>
<ListItem <ListItem
data-setting-target="settings.sections.externalController.fields.enable"
sx={{ sx={{
padding: '5px 2px', padding: '5px 2px',
display: 'flex', display: 'flex',
@@ -144,6 +148,7 @@ export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) {
</ListItem> </ListItem>
<ListItem <ListItem
data-setting-target="settings.sections.externalController.fields.address"
sx={{ sx={{
padding: '5px 2px', padding: '5px 2px',
display: 'flex', display: 'flex',
@@ -184,6 +189,7 @@ export function ControllerViewer({ ref }: { ref?: Ref<DialogRef> }) {
</ListItem> </ListItem>
<ListItem <ListItem
data-setting-target="settings.sections.externalController.fields.secret"
sx={{ sx={{
padding: '5px 2px', padding: '5px 2px',
display: 'flex', display: 'flex',
+205 -225
View File
@@ -16,14 +16,7 @@ import {
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import type { Ref } from 'react' import type { Ref } from 'react'
import { import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
useCallback,
useEffect,
useImperativeHandle,
useReducer,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -47,6 +40,8 @@ import { useThemeMode } from '@/services/states'
import type { MonacoEditorInstance } from '@/types/monaco' import type { MonacoEditorInstance } from '@/types/monaco'
import { MONACO_FONT_FAMILY } from '@/utils/font-family' import { MONACO_FONT_FAMILY } from '@/utils/font-family'
import { useSettingDialog } from '../settings-search-context'
const Item = styled(ListItem)(() => ({ const Item = styled(ListItem)(() => ({
padding: '5px 2px', padding: '5px 2px',
'& textarea': { '& textarea': {
@@ -190,6 +185,110 @@ const DEFAULT_DNS_CONFIG = {
}, },
} }
type DnsFormValues = {
enable: boolean
listen: string
enhancedMode: 'fake-ip' | 'redir-host'
fakeIpRange: string
fakeIpRange6: string
fakeIpFilterMode: 'blacklist' | 'whitelist'
preferH3: boolean
respectRules: boolean
useHosts: boolean
useSystemHosts: boolean
ipv6: boolean
fakeIpFilter: string
nameserver: string
fallback: string
defaultNameserver: string
proxyServerNameserver: string
directNameserver: string
directNameserverFollowPolicy: boolean
fallbackGeoip: boolean
fallbackGeoipCode: string
fallbackIpcidr: string
fallbackDomain: string
nameserverPolicy: string
hosts: string // hosts设置,独立于dns
}
const DEFAULT_FORM_VALUES: DnsFormValues = {
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'],
fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'],
fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'],
fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'],
preferH3: DEFAULT_DNS_CONFIG['prefer-h3'],
respectRules: DEFAULT_DNS_CONFIG['respect-rules'],
useHosts: DEFAULT_DNS_CONFIG['use-hosts'],
useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'],
ipv6: DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '),
defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '),
fallback: DEFAULT_DNS_CONFIG.fallback.join(', '),
proxyServerNameserver:
DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '',
directNameserver: DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '',
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'],
fallbackIpcidr:
DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '',
fallbackDomain:
DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '',
nameserverPolicy: '',
hosts: '',
}
function buildDnsConfig(values: DnsFormValues) {
const dnsConfig: any = {
enable: values.enable,
listen: values.listen,
'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange,
'fake-ip-range6': values.fakeIpRange6,
'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3,
'respect-rules': values.respectRules,
'use-hosts': values.useHosts,
'use-system-hosts': values.useSystemHosts,
ipv6: values.ipv6,
'fake-ip-filter': parseList(values.fakeIpFilter),
'default-nameserver': parseList(values.defaultNameserver),
nameserver: parseList(values.nameserver),
'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy),
'direct-nameserver-follow-policy': values.directNameserverFollowPolicy,
'fallback-filter': {
geoip: values.fallbackGeoip,
'geoip-code': values.fallbackGeoipCode,
ipcidr: parseList(values.fallbackIpcidr),
domain: parseList(values.fallbackDomain),
},
fallback: parseList(values.fallback),
'proxy-server-nameserver': parseList(values.proxyServerNameserver),
'direct-nameserver': parseList(values.directNameserver),
}
return dnsConfig
}
function buildDnsYaml(values: DnsFormValues) {
const config: Record<string, any> = {}
const dnsConfig = buildDnsConfig(values)
if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig
}
config.hosts = parseHosts(values.hosts)
return yaml.dump(config, { forceQuotes: true })
}
export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) { export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation() const { t } = useTranslation()
const { mutateClash } = useClash() const { mutateClash } = useClash()
@@ -203,64 +302,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const themeMode = useThemeMode() const themeMode = useThemeMode()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [initializing, setInitializing] = useState(false)
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
const skipYamlSyncRef = useRef(false)
const editorRef = useRef<MonacoEditorInstance | null>(null) const editorRef = useRef<MonacoEditorInstance | null>(null)
const [values, setValues] = useState<{ const [values, setValues] = useState<DnsFormValues>(DEFAULT_FORM_VALUES)
enable: boolean
listen: string
enhancedMode: 'fake-ip' | 'redir-host'
fakeIpRange: string
fakeIpRange6: string
fakeIpFilterMode: 'blacklist' | 'whitelist'
preferH3: boolean
respectRules: boolean
useHosts: boolean
useSystemHosts: boolean
ipv6: boolean
fakeIpFilter: string
nameserver: string
fallback: string
defaultNameserver: string
proxyServerNameserver: string
directNameserver: string
directNameserverFollowPolicy: boolean
fallbackGeoip: boolean
fallbackGeoipCode: string
fallbackIpcidr: string
fallbackDomain: string
nameserverPolicy: string
hosts: string // hosts设置,独立于dns
}>({
enable: DEFAULT_DNS_CONFIG.enable,
listen: DEFAULT_DNS_CONFIG.listen,
enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'],
fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'],
fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'],
fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'],
preferH3: DEFAULT_DNS_CONFIG['prefer-h3'],
respectRules: DEFAULT_DNS_CONFIG['respect-rules'],
useHosts: DEFAULT_DNS_CONFIG['use-hosts'],
useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'],
ipv6: DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '),
defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '),
fallback: DEFAULT_DNS_CONFIG.fallback.join(', '),
proxyServerNameserver:
DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '',
directNameserver: DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '',
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'],
fallbackIpcidr:
DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '',
fallbackDomain:
DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '',
nameserverPolicy: '',
hosts: '',
})
// Prevent YAML-to-form sync before configuration loads. // Prevent YAML-to-form sync before configuration loads.
const [yamlContent, setYamlContent] = useReducer( const [yamlContent, setYamlContent] = useReducer(
@@ -269,7 +314,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
) )
const updateValuesFromConfig = useCallback((config: any) => { const updateValuesFromConfig = useCallback((config: any) => {
if (!config) return if (!config) return undefined
const dnsConfig = config.dns || {} const dnsConfig = config.dns || {}
const hostsConfig = config.hosts || {} const hostsConfig = config.hosts || {}
@@ -289,7 +334,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
? fakeIpFilterMode ? fakeIpFilterMode
: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'] : DEFAULT_DNS_CONFIG['fake-ip-filter-mode']
setValues({ const next: DnsFormValues = {
enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable, enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable,
listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen, listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen,
enhancedMode: validEnhancedMode, enhancedMode: validEnhancedMode,
@@ -341,127 +386,27 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
nameserverPolicy: nameserverPolicy:
formatNameserverPolicy(dnsConfig['nameserver-policy']) || '', formatNameserverPolicy(dnsConfig['nameserver-policy']) || '',
hosts: formatHosts(hostsConfig) || '', hosts: formatHosts(hostsConfig) || '',
}) }
setValues(next)
return next
}, []) }, [])
const generateDnsConfig = useCallback(() => {
const dnsConfig: any = {
enable: values.enable,
listen: values.listen,
'enhanced-mode': values.enhancedMode,
'fake-ip-range': values.fakeIpRange,
'fake-ip-range6': values.fakeIpRange6,
'fake-ip-filter-mode': values.fakeIpFilterMode,
'prefer-h3': values.preferH3,
'respect-rules': values.respectRules,
'use-hosts': values.useHosts,
'use-system-hosts': values.useSystemHosts,
ipv6: values.ipv6,
'fake-ip-filter': parseList(values.fakeIpFilter),
'default-nameserver': parseList(values.defaultNameserver),
nameserver: parseList(values.nameserver),
'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy),
'direct-nameserver-follow-policy': values.directNameserverFollowPolicy,
'fallback-filter': {
geoip: values.fallbackGeoip,
'geoip-code': values.fallbackGeoipCode,
ipcidr: parseList(values.fallbackIpcidr),
domain: parseList(values.fallbackDomain),
},
fallback: parseList(values.fallback),
'proxy-server-nameserver': parseList(values.proxyServerNameserver),
'direct-nameserver': parseList(values.directNameserver),
}
return dnsConfig
}, [values])
const updateYamlFromValues = useCallback(() => {
const config: Record<string, any> = {}
const dnsConfig = generateDnsConfig()
if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig
}
config.hosts = parseHosts(values.hosts)
setYamlContent(yaml.dump(config, { forceQuotes: true }))
}, [generateDnsConfig, values.hosts])
const resetToDefaults = useCallback(() => { const resetToDefaults = useCallback(() => {
setValues({ setValues(DEFAULT_FORM_VALUES)
enable: DEFAULT_DNS_CONFIG.enable, setYamlContent(buildDnsYaml(DEFAULT_FORM_VALUES))
listen: DEFAULT_DNS_CONFIG.listen, }, [])
enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'],
fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'],
fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'],
fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'],
preferH3: DEFAULT_DNS_CONFIG['prefer-h3'],
respectRules: DEFAULT_DNS_CONFIG['respect-rules'],
useHosts: DEFAULT_DNS_CONFIG['use-hosts'],
useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'],
ipv6: DEFAULT_DNS_CONFIG.ipv6,
fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '),
defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '),
nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '),
fallback: DEFAULT_DNS_CONFIG.fallback.join(', '),
proxyServerNameserver:
DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '',
directNameserver:
DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '',
directNameserverFollowPolicy:
DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false,
fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip,
fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'],
fallbackIpcidr:
DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '',
fallbackDomain:
DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '',
nameserverPolicy: '',
hosts: '',
})
updateYamlFromValues()
}, [updateYamlFromValues])
const updateValuesFromYaml = useCallback(() => { const updateValuesFromYaml = useCallback(() => {
try { try {
const parsedYaml = yaml.load(yamlContent) as any const parsedYaml = yaml.load(yamlContent) as any
if (!parsedYaml || parsedYaml.isInitValue) return if (!parsedYaml || parsedYaml.isInitValue) return
skipYamlSyncRef.current = true
updateValuesFromConfig(parsedYaml) updateValuesFromConfig(parsedYaml)
} catch { } catch {
showNotice.error('settings.modals.dns.errors.invalidYaml') showNotice.error('settings.modals.dns.errors.invalidYaml')
} }
}, [yamlContent, updateValuesFromConfig]) }, [yamlContent, updateValuesFromConfig])
useEffect(() => {
if (skipYamlSyncRef.current) {
skipYamlSyncRef.current = false
return
}
updateYamlFromValues()
}, [updateYamlFromValues])
const latestUpdateValuesFromYamlRef = useRef(updateValuesFromYaml)
const latestUpdateYamlFromValuesRef = useRef(updateYamlFromValues)
useEffect(() => {
latestUpdateValuesFromYamlRef.current = updateValuesFromYaml
latestUpdateYamlFromValuesRef.current = updateYamlFromValues
}, [updateValuesFromYaml, updateYamlFromValues])
useEffect(() => {
if (visualization) {
latestUpdateValuesFromYamlRef.current()
} else {
latestUpdateYamlFromValuesRef.current()
}
}, [visualization])
useEffect(() => { useEffect(() => {
return () => { return () => {
editorRef.current?.dispose() editorRef.current?.dispose()
@@ -470,34 +415,33 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}, []) }, [])
const initDnsConfig = useCallback(async () => { const initDnsConfig = useCallback(async () => {
setInitializing(true)
try { try {
const dnsConfig = await getDnsConfigContent() const dnsConfig = await getDnsConfigContent()
if (dnsConfig !== null) { if (dnsConfig !== null) {
const config = yaml.load(dnsConfig) as any const config = yaml.load(dnsConfig) as any
updateValuesFromConfig(config) const loaded = updateValuesFromConfig(config)
setYamlContent(dnsConfig) setYamlContent(loaded ? buildDnsYaml(loaded) : dnsConfig)
} else { } else {
resetToDefaults() resetToDefaults()
} }
} catch (err) { } catch (err) {
console.error('Failed to initialize DNS config', err) console.error('Failed to initialize DNS config', err)
resetToDefaults() resetToDefaults()
} finally {
setInitializing(false)
} }
}, [resetToDefaults, updateValuesFromConfig]) }, [resetToDefaults, updateValuesFromConfig])
useImperativeHandle( useSettingDialog(ref, 'dns-viewer', open, () => ({
ref, open: () => {
() => ({ setOpen(true)
open: () => { void initDnsConfig()
setOpen(true) },
void initDnsConfig() close: () => setOpen(false),
}, }))
close: () => setOpen(false),
}),
[initDnsConfig],
)
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
try { try {
@@ -506,7 +450,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
if (visualization) { if (visualization) {
config = {} config = {}
const dnsConfig = generateDnsConfig() const dnsConfig = buildDnsConfig(values)
if (Object.keys(dnsConfig).length > 0) { if (Object.keys(dnsConfig).length > 0) {
config.dns = dnsConfig config.dns = dnsConfig
} }
@@ -586,20 +530,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
? event.target.checked ? event.target.checked
: event.target.value : event.target.value
setValues((prev) => { setValues((prev) => ({
const newValues = { ...prev,
...prev, [field]: value,
[field]: value, }))
}
if (visualization) {
setTimeout(() => {
updateYamlFromValues()
}, 0)
}
return newValues
})
} }
return ( return (
@@ -617,6 +551,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.dns.dialog.title')} {t('settings.modals.dns.dialog.title')}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button <Button
data-setting-target="dns-viewer-reset"
variant="outlined" variant="outlined"
size="small" size="small"
color="warning" color="warning"
@@ -628,8 +563,14 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
data-setting-target="dns-mode"
onClick={() => { onClick={() => {
setVisualization((prev) => !prev) if (visualization) {
setYamlContent(buildDnsYaml(values))
} else {
updateValuesFromYaml()
}
setVisualization(!visualization)
}} }}
> >
{visualization {visualization
@@ -652,6 +593,15 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span
data-settings-dialog="dns-viewer"
data-search-ready={!initializing}
/>
{initializing && (
<Typography role="status" data-settings-loading tabIndex={-1}>
{t('shared.statuses.loading')}
</Typography>
)}
<Alert severity="info" sx={{ mb: 2 }}> <Alert severity="info" sx={{ mb: 2 }}>
{t('settings.modals.dns.dialog.profileScope')} {t('settings.modals.dns.dialog.profileScope')}
</Alert> </Alert>
@@ -672,7 +622,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.dns.sections.general')} {t('settings.modals.dns.sections.general')}
</Typography> </Typography>
<Item> <Item data-setting-target="settings.modals.dns.fields.enable">
<ListItemText primary={t('settings.modals.dns.fields.enable')} /> <ListItemText primary={t('settings.modals.dns.fields.enable')} />
<Switch <Switch
edge="end" edge="end"
@@ -681,7 +631,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.listen">
<ListItemText primary={t('settings.modals.dns.fields.listen')} /> <ListItemText primary={t('settings.modals.dns.fields.listen')} />
<TextField <TextField
size="small" size="small"
@@ -694,7 +644,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.enhancedMode">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.enhancedMode')} primary={t('settings.modals.dns.fields.enhancedMode')}
/> />
@@ -709,7 +659,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
</FormControl> </FormControl>
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.fakeIpRange">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fakeIpRange')} primary={t('settings.modals.dns.fields.fakeIpRange')}
/> />
@@ -724,7 +674,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.fakeIpRange6">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fakeIpRange6')} primary={t('settings.modals.dns.fields.fakeIpRange6')}
/> />
@@ -739,7 +689,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.fakeIpFilterMode">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fakeIpFilterMode')} primary={t('settings.modals.dns.fields.fakeIpFilterMode')}
/> />
@@ -754,7 +704,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
</FormControl> </FormControl>
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.ipv6.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.ipv6.label')} primary={t('settings.modals.dns.fields.ipv6.label')}
secondary={t('settings.modals.dns.fields.ipv6.description')} secondary={t('settings.modals.dns.fields.ipv6.description')}
@@ -766,7 +716,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.preferH3.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.preferH3.label')} primary={t('settings.modals.dns.fields.preferH3.label')}
secondary={t('settings.modals.dns.fields.preferH3.description')} secondary={t('settings.modals.dns.fields.preferH3.description')}
@@ -778,7 +728,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.respectRules.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.respectRules.label')} primary={t('settings.modals.dns.fields.respectRules.label')}
secondary={t( secondary={t(
@@ -792,7 +742,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.useHosts.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.useHosts.label')} primary={t('settings.modals.dns.fields.useHosts.label')}
secondary={t('settings.modals.dns.fields.useHosts.description')} secondary={t('settings.modals.dns.fields.useHosts.description')}
@@ -804,7 +754,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.useSystemHosts.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.useSystemHosts.label')} primary={t('settings.modals.dns.fields.useSystemHosts.label')}
secondary={t( secondary={t(
@@ -818,7 +768,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.directPolicy.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.directPolicy.label')} primary={t('settings.modals.dns.fields.directPolicy.label')}
secondary={t( secondary={t(
@@ -832,7 +782,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.defaultNameserver.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.defaultNameserver.label')} primary={t('settings.modals.dns.fields.defaultNameserver.label')}
secondary={t( secondary={t(
@@ -852,7 +805,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.nameserver.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.nameserver.label')} primary={t('settings.modals.dns.fields.nameserver.label')}
secondary={t('settings.modals.dns.fields.nameserver.description')} secondary={t('settings.modals.dns.fields.nameserver.description')}
@@ -870,7 +826,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.fallback.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fallback.label')} primary={t('settings.modals.dns.fields.fallback.label')}
secondary={t('settings.modals.dns.fields.fallback.description')} secondary={t('settings.modals.dns.fields.fallback.description')}
@@ -888,7 +847,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.proxy.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.proxy.label')} primary={t('settings.modals.dns.fields.proxy.label')}
secondary={t('settings.modals.dns.fields.proxy.description')} secondary={t('settings.modals.dns.fields.proxy.description')}
@@ -906,7 +868,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.directNameserver.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.directNameserver.label')} primary={t('settings.modals.dns.fields.directNameserver.label')}
secondary={t( secondary={t(
@@ -926,7 +891,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.fakeIpFilter.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fakeIpFilter.label')} primary={t('settings.modals.dns.fields.fakeIpFilter.label')}
secondary={t( secondary={t(
@@ -946,7 +914,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.nameserverPolicy.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.nameserverPolicy.label')} primary={t('settings.modals.dns.fields.nameserverPolicy.label')}
secondary={t( secondary={t(
@@ -973,7 +944,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.dns.sections.fallbackFilter')} {t('settings.modals.dns.sections.fallbackFilter')}
</Typography> </Typography>
<Item> <Item data-setting-target="settings.modals.dns.fields.geoipFiltering.label">
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.geoipFiltering.label')} primary={t('settings.modals.dns.fields.geoipFiltering.label')}
secondary={t( secondary={t(
@@ -987,7 +958,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.modals.dns.fields.geoipCode">
<ListItemText primary={t('settings.modals.dns.fields.geoipCode')} /> <ListItemText primary={t('settings.modals.dns.fields.geoipCode')} />
<TextField <TextField
size="small" size="small"
@@ -1000,7 +971,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.fallbackIpCidr.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fallbackIpCidr.label')} primary={t('settings.modals.dns.fields.fallbackIpCidr.label')}
secondary={t( secondary={t(
@@ -1020,7 +994,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</Item> </Item>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.fallbackDomain.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.fallbackDomain.label')} primary={t('settings.modals.dns.fields.fallbackDomain.label')}
secondary={t( secondary={t(
@@ -1047,7 +1024,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
{t('settings.modals.dns.sections.hosts')} {t('settings.modals.dns.sections.hosts')}
</Typography> </Typography>
<Item sx={{ flexDirection: 'column', alignItems: 'flex-start' }}> <Item
data-setting-target="settings.modals.dns.fields.hosts.label"
sx={{ flexDirection: 'column', alignItems: 'flex-start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.dns.fields.hosts.label')} primary={t('settings.modals.dns.fields.hosts.label')}
secondary={t('settings.modals.dns.fields.hosts.description')} secondary={t('settings.modals.dns.fields.hosts.description')}
@@ -1,7 +1,7 @@
import { Delete as DeleteIcon } from '@mui/icons-material' import { Delete as DeleteIcon } from '@mui/icons-material'
import { Box, Button, Divider, List, ListItem, TextField } from '@mui/material' import { Box, Button, Divider, List, ListItem, TextField } from '@mui/material'
import { useLockFn, useRequest } from 'ahooks' import { useLockFn, useRequest } from 'ahooks'
import { forwardRef, useImperativeHandle, useRef, useState } from 'react' import { forwardRef, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from '@/components/base' import { BaseDialog, Switch } from '@/components/base'
@@ -10,6 +10,8 @@ import { restartCore } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
// Development origins must never be persisted into production configuration. // Development origins must never be persisted into production configuration.
const DEV_URLS = [ const DEV_URLS = [
'tauri://localhost', 'tauri://localhost',
@@ -154,7 +156,7 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
}, },
) )
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'external-controller-cors', open, () => ({
open: () => { open: () => {
const cors = clash?.['external-controller-cors'] const cors = clash?.['external-controller-cors']
const origins = cors?.['allow-origins'] ?? [] const origins = cors?.['allow-origins'] ?? []
@@ -186,8 +188,12 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={handleSave} onOk={handleSave}
> >
<span data-settings-dialog="external-controller-cors" />
<List sx={{ width: '90%', padding: 2 }}> <List sx={{ width: '90%', padding: 2 }}>
<ListItem sx={{ padding: '8px 0' }}> <ListItem
data-setting-target="settings.sections.externalCors.fields.allowPrivateNetwork"
sx={{ padding: '8px 0' }}
>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -214,7 +220,10 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
<ListItem sx={{ padding: '8px 0' }}> <ListItem
data-setting-target="settings.sections.externalCors.fields.allowedOrigins"
sx={{ padding: '8px 0' }}
>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<div style={{ marginBottom: 8, fontWeight: 'bold' }}> <div style={{ marginBottom: 8, fontWeight: 'bold' }}>
{t('settings.sections.externalCors.fields.allowedOrigins')} {t('settings.sections.externalCors.fields.allowedOrigins')}
@@ -252,6 +261,7 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
</div> </div>
))} ))}
<Button <Button
data-setting-target="settings.sections.externalCors.actions.add"
variant="contained" variant="contained"
size="small" size="small"
onClick={handleAddOrigin} onClick={handleAddOrigin}
+10 -4
View File
@@ -1,12 +1,14 @@
import { styled, Typography } from '@mui/material' import { styled, Typography } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { forwardRef, useImperativeHandle, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch } from '@/components/base' import { BaseDialog, DialogRef, Switch } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
import { HotkeyInput } from './hotkey-input' import { HotkeyInput } from './hotkey-input'
const ItemWrapper = styled('div')` const ItemWrapper = styled('div')`
@@ -51,7 +53,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
verge?.enable_global_hotkey ?? true, verge?.enable_global_hotkey ?? true,
) )
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'hotkey-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
@@ -111,7 +113,11 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<ItemWrapper style={{ marginBottom: 16 }}> <span data-settings-dialog="hotkey-viewer" />
<ItemWrapper
data-setting-target="settings.modals.hotkey.toggles.enableGlobal"
style={{ marginBottom: 16 }}
>
<Typography> <Typography>
{t('settings.modals.hotkey.toggles.enableGlobal')} {t('settings.modals.hotkey.toggles.enableGlobal')}
</Typography> </Typography>
@@ -123,7 +129,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
</ItemWrapper> </ItemWrapper>
{HOTKEY_FUNC.map((func) => ( {HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}> <ItemWrapper key={func} data-setting-target={HOTKEY_FUNC_LABELS[func]}>
<Typography>{t(HOTKEY_FUNC_LABELS[func])}</Typography> <Typography>{t(HOTKEY_FUNC_LABELS[func])}</Typography>
<HotkeyInput <HotkeyInput
value={hotkeyMap[func] ?? []} value={hotkeyMap[func] ?? []}
+23 -19
View File
@@ -14,7 +14,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { join } from '@tauri-apps/api/path' import { join } from '@tauri-apps/api/path'
import { open as openDialog } from '@tauri-apps/plugin-dialog' import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { exists } from '@tauri-apps/plugin-fs' import { exists } from '@tauri-apps/plugin-fs'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
@@ -26,6 +26,8 @@ import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { useSettingDialog } from '../settings-search-context'
import { GuardState } from './guard-state' import { GuardState } from './guard-state'
const OS = getSystem() const OS = getSystem()
@@ -97,7 +99,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
} }
} }
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'layout-viewer', open, () => ({
open: () => setOpen(true), open: () => setOpen(true),
close: () => setOpen(false), close: () => setOpen(false),
})) }))
@@ -120,8 +122,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
> >
<span data-settings-dialog="layout-viewer" />
<List> <List>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.preferSystemTitlebar">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.preferSystemTitlebar', 'settings.components.verge.layout.fields.preferSystemTitlebar',
@@ -140,7 +143,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.trafficGraph">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.trafficGraph')} primary={t('settings.components.verge.layout.fields.trafficGraph')}
/> />
@@ -156,7 +159,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.memoryUsage">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.memoryUsage')} primary={t('settings.components.verge.layout.fields.memoryUsage')}
/> />
@@ -172,7 +175,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.proxyGroupIcon">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.proxyGroupIcon', 'settings.components.verge.layout.fields.proxyGroupIcon',
@@ -190,7 +193,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.pauseRenderTrafficStatsOnBlur">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.pauseRenderTrafficStatsOnBlur', 'settings.components.verge.layout.fields.pauseRenderTrafficStatsOnBlur',
@@ -212,7 +215,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.toastPosition">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.toastPosition')} primary={t('settings.components.verge.layout.fields.toastPosition')}
/> />
@@ -248,7 +251,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.hoverNavigator">
<ListItemText <ListItemText
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -276,7 +279,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.hoverNavigatorDelay">
<ListItemText <ListItemText
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -335,7 +338,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.navIcon">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.navIcon')} primary={t('settings.components.verge.layout.fields.navIcon')}
/> />
@@ -360,7 +363,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.collapseNavBar">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.collapseNavBar', 'settings.components.verge.layout.fields.collapseNavBar',
@@ -379,7 +382,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</Item> </Item>
{OS === 'macos' && ( {OS === 'macos' && (
<Item> <Item data-setting-target="settings.components.verge.layout.fields.trayIcon">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.trayIcon')} primary={t('settings.components.verge.layout.fields.trayIcon')}
/> />
@@ -407,7 +410,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</Item> </Item>
)} )}
{OS === 'macos' && ( {OS === 'macos' && (
<Item> <Item data-setting-target="settings.components.verge.layout.fields.enableTraySpeed">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.enableTraySpeed', 'settings.components.verge.layout.fields.enableTraySpeed',
@@ -445,7 +448,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
)} */} )} */}
<Item> <Item data-setting-target="settings.components.verge.layout.fields.proxyGroupsDisplayMode">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.proxyGroupsDisplayMode', 'settings.components.verge.layout.fields.proxyGroupsDisplayMode',
@@ -481,7 +484,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</Select> </Select>
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.showOutboundModesInline">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.showOutboundModesInline', 'settings.components.verge.layout.fields.showOutboundModesInline',
@@ -499,7 +502,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.commonTrayIcon">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.commonTrayIcon', 'settings.components.verge.layout.fields.commonTrayIcon',
@@ -512,6 +515,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
onGuard={(e) => patchVerge({ common_tray_icon: e })} onGuard={(e) => patchVerge({ common_tray_icon: e })}
> >
<Button <Button
data-setting-target="settings.components.verge.basic.actions.browse"
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={ startIcon={
@@ -554,7 +558,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.systemProxyTrayIcon">
<ListItemText <ListItemText
primary={t( primary={t(
'settings.components.verge.layout.fields.systemProxyTrayIcon', 'settings.components.verge.layout.fields.systemProxyTrayIcon',
@@ -609,7 +613,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
</GuardState> </GuardState>
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.layout.fields.tunTrayIcon">
<ListItemText <ListItemText
primary={t('settings.components.verge.layout.fields.tunTrayIcon')} primary={t('settings.components.verge.layout.fields.tunTrayIcon')}
/> />
@@ -8,7 +8,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
@@ -17,6 +17,8 @@ import { entry_lightweight_mode } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) { export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation() const { t } = useTranslation()
const { verge, patchVerge } = useVerge() const { verge, patchVerge } = useVerge()
@@ -27,7 +29,7 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
autoEnterLiteModeDelay: 10, // 默认10分钟 autoEnterLiteModeDelay: 10, // 默认10分钟
}) })
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'lite-mode-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setValues({ setValues({
@@ -61,8 +63,12 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="lite-mode-viewer" />
<List> <List>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.liteMode.actions.enterNow"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.liteMode.actions.enterNow')} primary={t('settings.modals.liteMode.actions.enterNow')}
/> />
@@ -84,7 +90,10 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
</Typography> </Typography>
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.liteMode.toggles.autoEnter"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.liteMode.toggles.autoEnter')} primary={t('settings.modals.liteMode.toggles.autoEnter')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -105,7 +114,10 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
{values.autoEnterLiteMode && ( {values.autoEnterLiteMode && (
<> <>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.liteMode.fields.delay"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.liteMode.fields.delay')} primary={t('settings.modals.liteMode.fields.delay')}
/> />
+53 -14
View File
@@ -8,13 +8,15 @@ import {
TextField, TextField,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { forwardRef, useImperativeHandle, useState } from 'react' import { forwardRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
const buildInitialValues = (verge?: IVergeConfig | null) => ({ const buildInitialValues = (verge?: IVergeConfig | null) => ({
appLogLevel: verge?.app_log_level ?? 'warn', appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128, appLogMaxSize: verge?.app_log_max_size ?? 128,
@@ -38,7 +40,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [values, setValues] = useState(() => buildInitialValues(null)) const [values, setValues] = useState(() => buildInitialValues(null))
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'misc-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setValues(buildInitialValues(verge)) setValues(buildInitialValues(verge))
@@ -80,8 +82,12 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="misc-viewer" />
<List> <List>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.appLogLevel"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.appLogLevel')} primary={t('settings.modals.misc.fields.appLogLevel')}
/> />
@@ -104,7 +110,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</Select> </Select>
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.appLogMaxSize"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.appLogMaxSize')} primary={t('settings.modals.misc.fields.appLogMaxSize')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -136,7 +145,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.appLogMaxCount"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.appLogMaxCount')} primary={t('settings.modals.misc.fields.appLogMaxCount')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -168,7 +180,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.autoCloseConnections"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.autoCloseConnections')} primary={t('settings.modals.misc.fields.autoCloseConnections')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -187,7 +202,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.autoCheckUpdate"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.autoCheckUpdate')} primary={t('settings.modals.misc.fields.autoCheckUpdate')}
/> />
@@ -200,7 +218,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.enableBuiltinEnhanced"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.enableBuiltinEnhanced')} primary={t('settings.modals.misc.fields.enableBuiltinEnhanced')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -219,7 +240,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.proxyLayoutColumns"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.proxyLayoutColumns')} primary={t('settings.modals.misc.fields.proxyLayoutColumns')}
/> />
@@ -245,7 +269,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</Select> </Select>
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.autoLogClean"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.autoLogClean')} primary={t('settings.modals.misc.fields.autoLogClean')}
/> />
@@ -298,7 +325,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
</Select> </Select>
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.autoDelayDetection"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.autoDelayDetection')} primary={t('settings.modals.misc.fields.autoDelayDetection')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -317,7 +347,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.autoDelayDetectionInterval"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.modals.misc.fields.autoDelayDetectionInterval', 'settings.modals.misc.fields.autoDelayDetectionInterval',
@@ -355,7 +388,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.defaultLatencyTest"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.defaultLatencyTest')} primary={t('settings.modals.misc.fields.defaultLatencyTest')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -379,7 +415,10 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.misc.fields.defaultLatencyTimeout"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.misc.fields.defaultLatencyTimeout')} primary={t('settings.modals.misc.fields.defaultLatencyTimeout')}
/> />
@@ -2,19 +2,21 @@ import { ContentCopyRounded } from '@mui/icons-material'
import { alpha, Box, Button, CircularProgress, IconButton } from '@mui/material' import { alpha, Box, Button, CircularProgress, IconButton } from '@mui/material'
import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, BaseEmpty, DialogRef } from '@/components/base' import { BaseDialog, BaseEmpty, DialogRef } from '@/components/base'
import { useNetworkInterfaces } from '@/hooks/use-network' import { useNetworkInterfaces } from '@/hooks/use-network'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) { export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isV4, setIsV4] = useState(true) const [isV4, setIsV4] = useState(true)
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'network-interface-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
}, },
@@ -51,41 +53,49 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
> >
{loading && isEmpty ? ( <div
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> data-settings-dialog="network-interface-viewer"
<CircularProgress size={24} /> data-setting-target="network-interface-viewer"
</Box> tabIndex={-1}
) : isEmpty ? ( >
<Box sx={{ minHeight: 160 }}> {loading && isEmpty ? (
<BaseEmpty /> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
</Box> <CircularProgress size={24} />
) : (
networkInterfaces.map((item) => (
<Box key={item.name}>
<h4>{item.name}</h4>
<Box>
{item.addr.map((address) => {
const ip = getAddressIp(address)
return (
ip && (
<AddressDisplay
key={ip}
label={t(
'settings.modals.networkInterface.fields.ipAddress',
)}
content={ip}
/>
)
)
})}
<AddressDisplay
label={t('settings.modals.networkInterface.fields.macAddress')}
content={item.mac_addr ?? ''}
/>
</Box>
</Box> </Box>
)) ) : isEmpty ? (
)} <Box sx={{ minHeight: 160 }}>
<BaseEmpty />
</Box>
) : (
networkInterfaces.map((item) => (
<Box key={item.name}>
<h4>{item.name}</h4>
<Box>
{item.addr.map((address) => {
const ip = getAddressIp(address)
return (
ip && (
<AddressDisplay
key={ip}
label={t(
'settings.modals.networkInterface.fields.ipAddress',
)}
content={ip}
/>
)
)
})}
<AddressDisplay
label={t(
'settings.modals.networkInterface.fields.macAddress',
)}
content={item.mac_addr ?? ''}
/>
</Box>
</Box>
))
)}
</div>
</BaseDialog> </BaseDialog>
) )
} }
+4 -2
View File
@@ -13,6 +13,7 @@ import React, { ReactNode, useState } from 'react'
import isAsyncFunction from '@/utils/is-async-function' import isAsyncFunction from '@/utils/is-async-function'
interface ItemProps { interface ItemProps {
'data-setting-target'?: string
label: ReactNode label: ReactNode
extra?: ReactNode extra?: ReactNode
children?: ReactNode children?: ReactNode
@@ -21,6 +22,7 @@ interface ItemProps {
} }
export const SettingItem: React.FC<ItemProps> = ({ export const SettingItem: React.FC<ItemProps> = ({
'data-setting-target': searchTarget,
label, label,
extra, extra,
children, children,
@@ -49,7 +51,7 @@ export const SettingItem: React.FC<ItemProps> = ({
} }
return clickable ? ( return clickable ? (
<ListItem disablePadding> <ListItem data-setting-target={searchTarget} disablePadding>
<ListItemButton onClick={handleClick} disabled={isLoading}> <ListItemButton onClick={handleClick} disabled={isLoading}>
<ListItemText primary={primary} secondary={secondary} /> <ListItemText primary={primary} secondary={secondary} />
{isLoading ? ( {isLoading ? (
@@ -60,7 +62,7 @@ export const SettingItem: React.FC<ItemProps> = ({
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
) : ( ) : (
<ListItem sx={{ pt: '5px', pb: '5px' }}> <ListItem data-setting-target={searchTarget} sx={{ pt: '5px', pb: '5px' }}>
<ListItemText primary={primary} secondary={secondary} /> <ListItemText primary={primary} secondary={secondary} />
{children} {children}
</ListItem> </ListItem>
+74 -50
View File
@@ -13,13 +13,7 @@ import {
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { import { forwardRef, useMemo, useState } from 'react'
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -41,9 +35,12 @@ import {
getSystemHostname, getSystemHostname,
} from '@/services/cmds' } from '@/services/cmds'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { useSettingDialog } from '../settings-search-context'
const DEFAULT_PAC = `function FindProxyForURL(url, host) { const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;"; return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}` }`
@@ -128,13 +125,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} = verge ?? {} } = verge ?? {}
const [value, setValue] = useState(() => buildInitialValues(null)) const [value, setValue] = useState(() => buildInitialValues(null))
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>( const { data: embeddedServerPort } = useQuery({
null, queryKey: ['getEmbeddedServerPort'],
) queryFn: getEmbeddedServerPort,
})
useEffect(() => {
getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error)
}, [])
const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows]) const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows])
@@ -191,7 +185,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
setPacEditorSavedValue(nextPac) setPacEditorSavedValue(nextPac)
}) })
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'sysproxy-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setValue(buildInitialValues(verge)) setValue(buildInitialValues(verge))
@@ -363,41 +357,49 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
loading={saving} loading={saving}
disableOk={saving} disableOk={saving}
> >
<span data-settings-dialog="sysproxy-viewer" />
<List> <List>
<BaseFieldset <Box data-setting-target="sysproxy-status" tabIndex={-1}>
label={t('settings.modals.sysproxy.fieldsets.currentStatus')} <BaseFieldset
padding="15px 10px" label={t('settings.modals.sysproxy.fieldsets.currentStatus')}
> padding="15px 10px"
<FlexBox> >
<Typography className="label">
{t('settings.modals.sysproxy.fields.enableStatus')}
</Typography>
<Typography className="value">
{isProxyReallyEnabled
? t('shared.statuses.enabled')
: t('shared.statuses.disabled')}
</Typography>
</FlexBox>
{!value.pac && (
<FlexBox> <FlexBox>
<Typography className="label"> <Typography className="label">
{t('settings.modals.sysproxy.fields.serverAddr')} {t('settings.modals.sysproxy.fields.enableStatus')}
</Typography>
<Typography className="value">{getSystemProxyAddress}</Typography>
</FlexBox>
)}
{value.pac && (
<FlexBox>
<Typography className="label">
{t('settings.modals.sysproxy.fields.pacUrl')}
</Typography> </Typography>
<Typography className="value"> <Typography className="value">
{getCurrentPacUrl || '-'} {isProxyReallyEnabled
? t('shared.statuses.enabled')
: t('shared.statuses.disabled')}
</Typography> </Typography>
</FlexBox> </FlexBox>
)} {!value.pac && (
</BaseFieldset> <FlexBox>
<ListItem sx={{ padding: '5px 2px' }}> <Typography className="label">
{t('settings.modals.sysproxy.fields.serverAddr')}
</Typography>
<Typography className="value">
{getSystemProxyAddress}
</Typography>
</FlexBox>
)}
{value.pac && (
<FlexBox>
<Typography className="label">
{t('settings.modals.sysproxy.fields.pacUrl')}
</Typography>
<Typography className="value">
{getCurrentPacUrl || '-'}
</Typography>
</FlexBox>
)}
</BaseFieldset>
</Box>
<ListItem
data-setting-target="settings.modals.sysproxy.fields.proxyHost"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.proxyHost')} primary={t('settings.modals.sysproxy.fields.proxyHost')}
/> />
@@ -424,7 +426,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
}} }}
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.usePacMode"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.usePacMode')} primary={t('settings.modals.sysproxy.fields.usePacMode')}
/> />
@@ -436,7 +441,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.proxyGuard"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.proxyGuard')} primary={t('settings.modals.sysproxy.fields.proxyGuard')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -454,7 +462,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.guardDuration"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.guardDuration')} primary={t('settings.modals.sysproxy.fields.guardDuration')}
/> />
@@ -477,7 +488,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
/> />
</ListItem> </ListItem>
{!value.pac && ( {!value.pac && (
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.alwaysUseDefaultBypass"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.modals.sysproxy.fields.alwaysUseDefaultBypass', 'settings.modals.sysproxy.fields.alwaysUseDefaultBypass',
@@ -504,7 +518,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
)} )}
{!value.pac && ( {!value.pac && (
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.enableBypassCheck"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.enableBypassCheck')} primary={t('settings.modals.sysproxy.fields.enableBypassCheck')}
/> />
@@ -536,7 +553,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
setValue((v) => ({ ...v, bypass: nextValue })) setValue((v) => ({ ...v, bypass: nextValue }))
}} }}
renderHeader={(modeToggle) => ( renderHeader={(modeToggle) => (
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.proxyBypass"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.proxyBypass')} primary={t('settings.modals.sysproxy.fields.proxyBypass')}
/> />
@@ -564,12 +584,16 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
)} )}
{value.pac && ( {value.pac && (
<ListItem sx={{ padding: '5px 2px', alignItems: 'start' }}> <ListItem
data-setting-target="settings.modals.sysproxy.fields.pacScriptContent"
sx={{ padding: '5px 2px', alignItems: 'start' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.sysproxy.fields.pacScriptContent')} primary={t('settings.modals.sysproxy.fields.pacScriptContent')}
sx={{ padding: '3px 0' }} sx={{ padding: '3px 0' }}
/> />
<Button <Button
data-setting-target="settings.modals.sysproxy.actions.editPac"
startIcon={<EditRounded />} startIcon={<EditRounded />}
variant="outlined" variant="outlined"
onClick={openPacEditor} onClick={openPacEditor}
+9 -11
View File
@@ -9,13 +9,7 @@ import {
useTheme, useTheme,
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { import { useEffect, useMemo, useRef, useState } from 'react'
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef } from '@/components/base' import { BaseDialog, DialogRef } from '@/components/base'
@@ -24,6 +18,8 @@ import { useVerge } from '@/hooks/use-verge'
import { defaultDarkTheme, defaultTheme } from '@/pages/_theme' import { defaultDarkTheme, defaultTheme } from '@/pages/_theme'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { useSettingDialog } from '../settings-search-context'
export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) { export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) {
const { ref } = props const { ref } = props
const { t } = useTranslation() const { t } = useTranslation()
@@ -41,7 +37,7 @@ export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) {
themeRef.current = theme themeRef.current = theme
}, [theme]) }, [theme])
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'theme-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
setTheme({ ...theme_setting }) setTheme({ ...theme_setting })
@@ -128,7 +124,7 @@ export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) {
const renderItem = (labelKey: string, key: ThemeKey) => { const renderItem = (labelKey: string, key: ThemeKey) => {
const label = t(labelKey) const label = t(labelKey)
return ( return (
<Item key={key}> <Item key={key} data-setting-target={labelKey}>
<ListItemText primary={label} /> <ListItemText primary={label} />
<Round sx={{ background: theme[key] || dt[key] }} /> <Round sx={{ background: theme[key] || dt[key] }} />
<TextField <TextField
@@ -153,10 +149,11 @@ export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="theme-viewer" />
<List sx={{ pt: 0 }}> <List sx={{ pt: 0 }}>
{fieldDefinitions.map((field) => renderItem(field.labelKey, field.key))} {fieldDefinitions.map((field) => renderItem(field.labelKey, field.key))}
<Item> <Item data-setting-target="settings.components.verge.theme.fields.fontFamily">
<ListItemText <ListItemText
primary={t('settings.components.verge.theme.fields.fontFamily')} primary={t('settings.components.verge.theme.fields.fontFamily')}
/> />
@@ -167,11 +164,12 @@ export function ThemeViewer(props: { ref?: React.Ref<DialogRef> }) {
onKeyDown={(e) => e.key === 'Enter' && onSave()} onKeyDown={(e) => e.key === 'Enter' && onSave()}
/> />
</Item> </Item>
<Item> <Item data-setting-target="settings.components.verge.theme.fields.cssInjection">
<ListItemText <ListItemText
primary={t('settings.components.verge.theme.fields.cssInjection')} primary={t('settings.components.verge.theme.fields.cssInjection')}
/> />
<Button <Button
data-setting-target="settings.components.verge.theme.actions.editCss"
startIcon={<EditRounded />} startIcon={<EditRounded />}
variant="outlined" variant="outlined"
onClick={openCssEditor} onClick={openCssEditor}
+46 -12
View File
@@ -9,7 +9,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -26,6 +26,8 @@ import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
import { areValidIpCidrs } from '@/utils/network' import { areValidIpCidrs } from '@/utils/network'
import { useSettingDialog } from '../settings-search-context'
import { DnsModeSwitch } from './dns-mode-switch' import { DnsModeSwitch } from './dns-mode-switch'
import { StackModeSwitch } from './stack-mode-switch' import { StackModeSwitch } from './stack-mode-switch'
@@ -67,7 +69,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
? t('settings.modals.tun.messages.invalidRouteExcludeAddress') ? t('settings.modals.tun.messages.invalidRouteExcludeAddress')
: t('settings.modals.tun.messages.routeExcludeAddressHint') : t('settings.modals.tun.messages.routeExcludeAddressHint')
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'tun-viewer', open, () => ({
open: () => { open: () => {
setOpen(true) setOpen(true)
const nextAutoRoute = clash?.tun['auto-route'] ?? true const nextAutoRoute = clash?.tun['auto-route'] ?? true
@@ -152,6 +154,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6">{t('settings.modals.tun.title')}</Typography> <Typography variant="h6">{t('settings.modals.tun.title')}</Typography>
<Button <Button
data-setting-target="tun-viewer-reset"
variant="outlined" variant="outlined"
size="small" size="small"
onClick={async () => { onClick={async () => {
@@ -204,8 +207,12 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
onOk={onSave} onOk={onSave}
> >
<span data-settings-dialog="tun-viewer" />
<List> <List>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.stack"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.stack')} /> <ListItemText primary={t('settings.modals.tun.fields.stack')} />
<StackModeSwitch <StackModeSwitch
value={values.stack} value={values.stack}
@@ -218,7 +225,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.dnsMode"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.tun.fields.dnsMode')} primary={t('settings.modals.tun.fields.dnsMode')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -237,7 +247,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
</Box> </Box>
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.device"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.device')} /> <ListItemText primary={t('settings.modals.tun.fields.device')} />
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
@@ -254,7 +267,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.autoRoute"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.autoRoute')} /> <ListItemText primary={t('settings.modals.tun.fields.autoRoute')} />
<Switch <Switch
edge="end" edge="end"
@@ -270,7 +286,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
</ListItem> </ListItem>
{OS === 'linux' && ( {OS === 'linux' && (
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.autoRedirect"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.tun.fields.autoRedirect')} primary={t('settings.modals.tun.fields.autoRedirect')}
sx={{ maxWidth: 'fit-content' }} sx={{ maxWidth: 'fit-content' }}
@@ -294,7 +313,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
</ListItem> </ListItem>
)} )}
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.strictRoute"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.strictRoute')} /> <ListItemText primary={t('settings.modals.tun.fields.strictRoute')} />
<Switch <Switch
edge="end" edge="end"
@@ -303,7 +325,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.autoDetectInterface"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.tun.fields.autoDetectInterface')} primary={t('settings.modals.tun.fields.autoDetectInterface')}
/> />
@@ -316,7 +341,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.dnsHijack"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.dnsHijack')} /> <ListItemText primary={t('settings.modals.tun.fields.dnsHijack')} />
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
@@ -333,7 +361,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
/> />
</ListItem> </ListItem>
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.mtu"
sx={{ padding: '5px 2px' }}
>
<ListItemText primary={t('settings.modals.tun.fields.mtu')} /> <ListItemText primary={t('settings.modals.tun.fields.mtu')} />
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
@@ -365,7 +396,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
setValues((v) => ({ ...v, routeExcludeAddress: nextValue })) setValues((v) => ({ ...v, routeExcludeAddress: nextValue }))
} }
renderHeader={(modeToggle) => ( renderHeader={(modeToggle) => (
<ListItem sx={{ padding: '5px 2px' }}> <ListItem
data-setting-target="settings.modals.tun.fields.routeExcludeAddress"
sx={{ padding: '5px 2px' }}
>
<ListItemText <ListItemText
primary={t('settings.modals.tun.fields.routeExcludeAddress')} primary={t('settings.modals.tun.fields.routeExcludeAddress')}
/> />
+49 -36
View File
@@ -1,4 +1,3 @@
/* eslint-disable @eslint-react/set-state-in-effect */
import { Delete, ExpandLess, ExpandMore } from '@mui/icons-material' import { Delete, ExpandLess, ExpandMore } from '@mui/icons-material'
import { import {
Button, Button,
@@ -12,14 +11,7 @@ import {
Select, Select,
MenuItem, MenuItem,
} from '@mui/material' } from '@mui/material'
import { import { forwardRef, useMemo, useRef, useState } from 'react'
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base' import { BaseDialog } from '@/components/base'
@@ -42,6 +34,8 @@ import {
normalizeListenHost, normalizeListenHost,
} from '@/utils/network' } from '@/utils/network'
import { useSettingDialog } from '../settings-search-context'
interface TunnelsViewerRef { interface TunnelsViewerRef {
open: () => void open: () => void
close: () => void close: () => void
@@ -95,7 +89,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues) const [values, setValues] = useState<TunnelFormValues>(buildInitialValues)
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([]) const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'tunnels-viewer', open, () => ({
open: () => { open: () => {
setValues(buildInitialValues()) setValues(buildInitialValues())
setDraftTunnels(() => clash?.tunnels ?? []) setDraftTunnels(() => clash?.tunnels ?? [])
@@ -157,29 +151,20 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
const selectedProxyToken = selectedProxyOption const selectedProxyToken = selectedProxyOption
? proxyOptionToken(selectedProxyOption) ? proxyOptionToken(selectedProxyOption)
: '' : ''
const selectedGroupExists = // A group or member missing from the proxy view reads as the default.
!values.group || proxyGroups.some(({ name }) => name === values.group) const effectiveGroup = proxyGroups.some(({ name }) => name === values.group)
? values.group
: ''
const effectiveProxy = selectedProxyOption ? values.proxy : null
const latestProxyStateRef = useRef({ const latestProxyStateRef = useRef({
binding: values.proxy, binding: effectiveProxy,
options: proxyOptions, options: proxyOptions,
}) })
latestProxyStateRef.current = { latestProxyStateRef.current = {
binding: values.proxy, binding: effectiveProxy,
options: proxyOptions, options: proxyOptions,
} }
useEffect(() => {
if (selectedGroupExists && (!values.proxy || selectedProxyOption)) return
setValues((current) => {
if (current.group !== values.group || current.proxy !== values.proxy) {
return current
}
return selectedGroupExists
? { ...current, proxy: null }
: { ...current, group: '', proxy: null }
})
}, [selectedGroupExists, selectedProxyOption, values.group, values.proxy])
const handleSave = async () => { const handleSave = async () => {
try { try {
await patchClash({ tunnels: draftTunnels }) await patchClash({ tunnels: draftTunnels })
@@ -312,10 +297,14 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
}} }}
onOk={handleSave} onOk={handleSave}
> >
<span data-settings-dialog="tunnels-viewer" />
<List> <List>
{draftTunnels.length > 0 && ( {draftTunnels.length > 0 && (
<> <>
<ListItem sx={{ padding: '4px 0', opacity: 0.6 }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.existing"
sx={{ padding: '4px 0', opacity: 0.6 }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.sections.clash.form.fields.tunnels.existing', 'settings.sections.clash.form.fields.tunnels.existing',
@@ -325,6 +314,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
<List component="nav"> <List component="nav">
{tunnelEntries.map((item) => ( {tunnelEntries.map((item) => (
<ListItem <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.default"
key={`${item.key}`} key={`${item.key}`}
sx={{ padding: '4px 0' }} sx={{ padding: '4px 0' }}
secondaryAction={ secondaryAction={
@@ -353,6 +343,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
)} )}
<ListItemButton <ListItemButton
sx={{ padding: '4px 0', opacity: 0.8 }} sx={{ padding: '4px 0', opacity: 0.8 }}
data-setting-target="tunnels-expand"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
> >
<ListItemText <ListItemText
@@ -363,7 +354,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
{expanded ? <ExpandLess /> : <ExpandMore />} {expanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton> </ListItemButton>
{expanded && ( {expanded && (
<ListItem sx={{ padding: '8px 0' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.protocols"
sx={{ padding: '8px 0' }}
>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{/* 输入框区域 */} {/* 输入框区域 */}
{/* 协议 */} {/* 协议 */}
@@ -391,7 +385,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 本地监听地址 */} {/* 本地监听地址 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.localAddr"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.sections.clash.form.fields.tunnels.localAddr', 'settings.sections.clash.form.fields.tunnels.localAddr',
@@ -410,7 +407,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 本地监听端口 */} {/* 本地监听端口 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.localPort"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.sections.clash.form.fields.tunnels.localPort', 'settings.sections.clash.form.fields.tunnels.localPort',
@@ -430,7 +430,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 目标服务器地址 */} {/* 目标服务器地址 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.targetAddr"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.sections.clash.form.fields.tunnels.targetAddr', 'settings.sections.clash.form.fields.tunnels.targetAddr',
@@ -449,7 +452,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 目标服务器端口 */} {/* 目标服务器端口 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.targetPort"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={t( primary={t(
'settings.sections.clash.form.fields.tunnels.targetPort', 'settings.sections.clash.form.fields.tunnels.targetPort',
@@ -469,7 +475,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 代理组 */} {/* 代理组 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.proxyGroup"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={ primary={
<> <>
@@ -490,7 +499,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
<Select <Select
size="small" size="small"
sx={{ width: 200, '> div': { py: '7.5px' } }} sx={{ width: 200, '> div': { py: '7.5px' } }}
value={values.group} value={effectiveGroup}
displayEmpty displayEmpty
onChange={(e) => { onChange={(e) => {
const nextGroup = e.target.value as string const nextGroup = e.target.value as string
@@ -531,7 +540,10 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
</ListItem> </ListItem>
{/* 代理节点 */} {/* 代理节点 */}
<ListItem sx={{ padding: '6px 2px' }}> <ListItem
data-setting-target="settings.sections.clash.form.fields.tunnels.proxyNode"
sx={{ padding: '6px 2px' }}
>
<ListItemText <ListItemText
primary={ primary={
<> <>
@@ -569,7 +581,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
: null, : null,
})) }))
}} }}
disabled={!values.group} // 没选组就禁用 disabled={!effectiveGroup} // 没选组就禁用
> >
<MenuItem value=""> <MenuItem value="">
{t('settings.sections.clash.form.fields.tunnels.default')} {t('settings.sections.clash.form.fields.tunnels.default')}
@@ -592,6 +604,7 @@ export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
{/* 添加按钮 */} {/* 添加按钮 */}
<Button <Button
data-setting-target="settings.sections.clash.form.fields.tunnels.actions.add"
variant="contained" variant="contained"
size="small" size="small"
sx={{ sx={{
@@ -131,6 +131,7 @@ export const WebUIItem = (props: Props) => {
</Typography> </Typography>
<IconButton <IconButton
size="small" size="small"
data-setting-target="web-ui-open"
title={t('settings.modals.webUI.actions.openUrl')} title={t('settings.modals.webUI.actions.openUrl')}
color="inherit" color="inherit"
onClick={() => onOpenUrl?.(value)} onClick={() => onOpenUrl?.(value)}
@@ -139,6 +140,7 @@ export const WebUIItem = (props: Props) => {
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
data-setting-target="web-ui-edit"
title={t('shared.actions.edit')} title={t('shared.actions.edit')}
color="inherit" color="inherit"
onClick={() => { onClick={() => {
@@ -150,6 +152,7 @@ export const WebUIItem = (props: Props) => {
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
data-setting-target="web-ui-delete"
title={t('shared.actions.delete')} title={t('shared.actions.delete')}
color="inherit" color="inherit"
onClick={onDelete} onClick={onDelete}
+40 -31
View File
@@ -1,7 +1,7 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import type { Ref } from 'react' import type { Ref } from 'react'
import { useImperativeHandle, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, BaseEmpty, DialogRef } from '@/components/base' import { BaseDialog, BaseEmpty, DialogRef } from '@/components/base'
@@ -10,6 +10,8 @@ import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { openExternalUrl } from '@/utils/open-external-url' import { openExternalUrl } from '@/utils/open-external-url'
import { useSettingDialog } from '../settings-search-context'
import { WebUIItem } from './web-ui-item' import { WebUIItem } from './web-ui-item'
const DEFAULT_WEB_UI_LIST = [ const DEFAULT_WEB_UI_LIST = [
@@ -27,7 +29,7 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
useImperativeHandle(ref, () => ({ useSettingDialog(ref, 'web-ui-viewer', open, () => ({
open: () => setOpen(true), open: () => setOpen(true),
close: () => setOpen(false), close: () => setOpen(false),
})) }))
@@ -114,6 +116,7 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
variant="contained" variant="contained"
size="small" size="small"
disabled={editing} disabled={editing}
data-setting-target="web-ui-new"
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
> >
{t('shared.actions.new')} {t('shared.actions.new')}
@@ -132,36 +135,42 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
> >
{!editing && webUIList.length === 0 && ( <div
<BaseEmpty data-settings-dialog="web-ui-viewer"
extra={ data-setting-target="web-ui-viewer"
<Typography sx={{ mt: 2, fontSize: '12px' }}> tabIndex={-1}
{t('settings.modals.webUI.messages.placeholderInstruction')} >
</Typography> {!editing && webUIList.length === 0 && (
} <BaseEmpty
/> extra={
)} <Typography sx={{ mt: 2, fontSize: '12px' }}>
{t('settings.modals.webUI.messages.placeholderInstruction')}
</Typography>
}
/>
)}
{webUIEntries.map(({ item, index, key }) => ( {webUIEntries.map(({ item, index, key }) => (
<WebUIItem <WebUIItem
key={key} key={key}
value={item} value={item}
onChange={(v) => handleChange(index, v)} onChange={(v) => handleChange(index, v)}
onDelete={() => handleDelete(index)} onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl} onOpenUrl={handleOpenUrl}
/> />
))} ))}
{editing && ( {editing && (
<WebUIItem <WebUIItem
value="" value=""
onlyEdit onlyEdit
onChange={(v) => { onChange={(v) => {
setEditing(false) setEditing(false)
handleAdd(v || '') handleAdd(v || '')
}} }}
onCancel={() => setEditing(false)} onCancel={() => setEditing(false)}
/> />
)} )}
</div>
</BaseDialog> </BaseDialog>
) )
} }
+18 -2
View File
@@ -156,6 +156,7 @@ const SettingClash = ({ onError }: Props) => {
<HeaderConfiguration ref={corsRef} /> <HeaderConfiguration ref={corsRef} />
<TunnelsViewer ref={tunnelRef} /> <TunnelsViewer ref={tunnelRef} />
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.allowLan"
label={t('settings.sections.clash.form.fields.allowLan')} label={t('settings.sections.clash.form.fields.allowLan')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -181,6 +182,7 @@ const SettingClash = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.dnsOverwrite"
label={t('settings.sections.clash.form.fields.dnsOverwrite')} label={t('settings.sections.clash.form.fields.dnsOverwrite')}
extra={ extra={
<> <>
@@ -203,7 +205,10 @@ const SettingClash = ({ onError }: Props) => {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.sections.clash.form.fields.ipv6')}> <SettingItem
data-setting-target="settings.sections.clash.form.fields.ipv6"
label={t('settings.sections.clash.form.fields.ipv6')}
>
<GuardState <GuardState
value={ipv6 ?? false} value={ipv6 ?? false}
valueProps="checked" valueProps="checked"
@@ -217,6 +222,7 @@ const SettingClash = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.unifiedDelay"
label={t('settings.sections.clash.form.fields.unifiedDelay')} label={t('settings.sections.clash.form.fields.unifiedDelay')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -238,6 +244,7 @@ const SettingClash = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.logLevel"
label={t('settings.sections.clash.form.fields.logLevel')} label={t('settings.sections.clash.form.fields.logLevel')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -279,7 +286,10 @@ const SettingClash = ({ onError }: Props) => {
</GuardState> </GuardState>
</SettingItem> </SettingItem>
<SettingItem label={t('settings.sections.clash.form.fields.portConfig')}> <SettingItem
data-setting-target="settings.sections.clash.form.fields.portConfig"
label={t('settings.sections.clash.form.fields.portConfig')}
>
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
disabled={false} disabled={false}
@@ -294,6 +304,7 @@ const SettingClash = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.external"
label={t('settings.sections.clash.form.fields.external')} label={t('settings.sections.clash.form.fields.external')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -311,11 +322,13 @@ const SettingClash = ({ onError }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.webUI"
onClick={() => webRef.current?.open()} onClick={() => webRef.current?.open()}
label={t('settings.sections.clash.form.fields.webUI')} label={t('settings.sections.clash.form.fields.webUI')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.clashCore"
label={t('settings.sections.clash.form.fields.clashCore')} label={t('settings.sections.clash.form.fields.clashCore')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -329,6 +342,7 @@ const SettingClash = ({ onError }: Props) => {
{isWIN && ( {isWIN && (
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.openUwpTool"
onClick={invoke_uwp_tool} onClick={invoke_uwp_tool}
label={t('settings.sections.clash.form.fields.openUwpTool')} label={t('settings.sections.clash.form.fields.openUwpTool')}
extra={ extra={
@@ -341,11 +355,13 @@ const SettingClash = ({ onError }: Props) => {
)} )}
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.updateGeoData"
onClick={onUpdateGeo} onClick={onUpdateGeo}
label={t('settings.sections.clash.form.fields.updateGeoData')} label={t('settings.sections.clash.form.fields.updateGeoData')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.sections.clash.form.fields.tunnels.title"
label={t('settings.sections.clash.form.fields.tunnels.title')} label={t('settings.sections.clash.form.fields.tunnels.title')}
onClick={() => tunnelRef.current?.open()} onClick={() => tunnelRef.current?.open()}
/> />
+18 -9
View File
@@ -1,3 +1,4 @@
import { Box } from '@mui/material'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -29,17 +30,24 @@ const SettingSystem = ({ onError }: Props) => {
return ( return (
<SettingList title={t('settings.sections.system.title')}> <SettingList title={t('settings.sections.system.title')}>
<ProxyControlSwitches <Box data-setting-target="settings.sections.system.toggles.tunMode">
label={t('settings.sections.system.toggles.tunMode')} <ProxyControlSwitches
onError={onError} label={t('settings.sections.system.toggles.tunMode')}
/> onError={onError}
/>
</Box>
<ProxyControlSwitches <Box data-setting-target="settings.sections.system.toggles.systemProxy">
label={t('settings.sections.system.toggles.systemProxy')} <ProxyControlSwitches
onError={onError} label={t('settings.sections.system.toggles.systemProxy')}
/> onError={onError}
/>
</Box>
<SettingItem label={t('settings.sections.system.fields.autoLaunch')}> <SettingItem
data-setting-target="settings.sections.system.fields.autoLaunch"
label={t('settings.sections.system.fields.autoLaunch')}
>
<GuardState <GuardState
value={enable_auto_launch ?? false} value={enable_auto_launch ?? false}
valueProps="checked" valueProps="checked"
@@ -64,6 +72,7 @@ const SettingSystem = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.sections.system.fields.silentStart"
label={t('settings.sections.system.fields.silentStart')} label={t('settings.sections.system.fields.silentStart')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -90,6 +90,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
<LiteModeViewer ref={liteModeRef} /> <LiteModeViewer ref={liteModeRef} />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.backupSetting"
onClick={() => backupRef.current?.open()} onClick={() => backupRef.current?.open()}
label={t('settings.components.verge.advanced.fields.backupSetting')} label={t('settings.components.verge.advanced.fields.backupSetting')}
extra={ extra={
@@ -101,11 +102,13 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.runtimeConfig"
onClick={() => configRef.current?.open()} onClick={() => configRef.current?.open()}
label={t('settings.components.verge.advanced.fields.runtimeConfig')} label={t('settings.components.verge.advanced.fields.runtimeConfig')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.openConfDir"
onClick={openAppDir} onClick={openAppDir}
label={t('settings.components.verge.advanced.fields.openConfDir')} label={t('settings.components.verge.advanced.fields.openConfDir')}
extra={ extra={
@@ -117,21 +120,25 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.openCoreDir"
onClick={openCoreDir} onClick={openCoreDir}
label={t('settings.components.verge.advanced.fields.openCoreDir')} label={t('settings.components.verge.advanced.fields.openCoreDir')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.openLogsDir"
onClick={openLogsDir} onClick={openLogsDir}
label={t('settings.components.verge.advanced.fields.openLogsDir')} label={t('settings.components.verge.advanced.fields.openLogsDir')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.checkUpdates"
onClick={onCheckUpdate} onClick={onCheckUpdate}
label={t('settings.components.verge.advanced.fields.checkUpdates')} label={t('settings.components.verge.advanced.fields.checkUpdates')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.openDevTools"
onClick={() => { onClick={() => {
void mutate(() => openDevTools(), { void mutate(() => openDevTools(), {
id: 'open-devtools', id: 'open-devtools',
@@ -142,6 +149,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.liteModeSettings"
label={t('settings.components.verge.advanced.fields.liteModeSettings')} label={t('settings.components.verge.advanced.fields.liteModeSettings')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -153,6 +161,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.exit"
onClick={() => { onClick={() => {
void mutate(() => exitApp(), { void mutate(() => exitApp(), {
id: 'exit-app', id: 'exit-app',
@@ -163,6 +172,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.exportDiagnostics"
label={t('settings.components.verge.advanced.fields.exportDiagnostics')} label={t('settings.components.verge.advanced.fields.exportDiagnostics')}
extra={ extra={
<TooltipIcon <TooltipIcon
@@ -173,6 +183,7 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
></SettingItem> ></SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.components.verge.advanced.fields.vergeVersion"
label={t('settings.components.verge.advanced.fields.vergeVersion')} label={t('settings.components.verge.advanced.fields.vergeVersion')}
extra={ extra={
<TooltipIcon <TooltipIcon
+14 -1
View File
@@ -100,7 +100,10 @@ const SettingVergeBasic = ({ onError }: Props) => {
<UpdateViewer ref={updateRef} /> <UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} /> <BackupViewer ref={backupRef} />
<SettingItem label={t('settings.components.verge.basic.fields.language')}> <SettingItem
data-setting-target="settings.components.verge.basic.fields.language"
label={t('settings.components.verge.basic.fields.language')}
>
<GuardState <GuardState
value={language ?? 'en'} value={language ?? 'en'}
onCatch={onError} onCatch={onError}
@@ -119,6 +122,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.themeMode"
label={t('settings.components.verge.basic.fields.themeMode')} label={t('settings.components.verge.basic.fields.themeMode')}
> >
<GuardState <GuardState
@@ -133,6 +137,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
{OS !== 'linux' && ( {OS !== 'linux' && (
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.trayClickEvent"
label={t('settings.components.verge.basic.fields.trayClickEvent')} label={t('settings.components.verge.basic.fields.trayClickEvent')}
> >
<GuardState <GuardState
@@ -166,6 +171,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
)} )}
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.copyEnvType"
label={t('settings.components.verge.basic.fields.copyEnvType')} label={t('settings.components.verge.basic.fields.copyEnvType')}
extra={ extra={
<TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} /> <TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} />
@@ -189,6 +195,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.startPage"
label={t('settings.components.verge.basic.fields.startPage')} label={t('settings.components.verge.basic.fields.startPage')}
> >
<GuardState <GuardState
@@ -211,6 +218,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.startupScript"
label={t('settings.components.verge.basic.fields.startupScript')} label={t('settings.components.verge.basic.fields.startupScript')}
> >
<GuardState <GuardState
@@ -228,6 +236,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
endAdornment={ endAdornment={
<> <>
<Button <Button
data-setting-target="settings.components.verge.basic.actions.browse"
onClick={async () => { onClick={async () => {
const selected = await open({ const selected = await open({
directory: false, directory: false,
@@ -264,21 +273,25 @@ const SettingVergeBasic = ({ onError }: Props) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.themeSetting"
onClick={() => themeRef.current?.open()} onClick={() => themeRef.current?.open()}
label={t('settings.components.verge.basic.fields.themeSetting')} label={t('settings.components.verge.basic.fields.themeSetting')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.layoutSetting"
onClick={() => layoutRef.current?.open()} onClick={() => layoutRef.current?.open()}
label={t('settings.components.verge.basic.fields.layoutSetting')} label={t('settings.components.verge.basic.fields.layoutSetting')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.misc"
onClick={() => miscRef.current?.open()} onClick={() => miscRef.current?.open()}
label={t('settings.components.verge.basic.fields.misc')} label={t('settings.components.verge.basic.fields.misc')}
/> />
<SettingItem <SettingItem
data-setting-target="settings.components.verge.basic.fields.hotkeySetting"
onClick={() => hotkeyRef.current?.open()} onClick={() => hotkeyRef.current?.open()}
label={t('settings.components.verge.basic.fields.hotkeySetting')} label={t('settings.components.verge.basic.fields.hotkeySetting')}
/> />
+645
View File
@@ -0,0 +1,645 @@
import type { TranslationKey } from '@/types/generated/i18n-keys'
export interface SettingEntry {
id: string
name: TranslationKey
aliases: string
section: TranslationKey
path: TranslationKey
prerequisite?: string
target?: string
fallback?: string
dialog?: string
platforms?: readonly string[]
}
type GroupDefaults = Pick<SettingEntry, 'section' | 'path'> &
Partial<
Pick<SettingEntry, 'dialog' | 'fallback' | 'prerequisite' | 'aliases'>
>
type EntryOptions = Partial<Omit<SettingEntry, 'name'>>
type GroupEntries = Partial<Record<TranslationKey, string | EntryOptions>>
function group(defaults: GroupDefaults, entries: GroupEntries): SettingEntry[] {
return (
Object.entries(entries) as [TranslationKey, string | EntryOptions][]
).map(([name, value]) => {
const options = typeof value === 'string' ? { aliases: value } : value
return {
...defaults,
...options,
id: options.id ?? name,
name,
aliases: [options.aliases, defaults.aliases].filter(Boolean).join(' '),
}
})
}
const system = {
section: 'settings.sections.system.title',
path: 'settings.sections.system.title',
} satisfies GroupDefaults
const tun = {
section: 'settings.sections.system.title',
path: 'settings.modals.tun.title',
dialog: 'tun-viewer',
} satisfies GroupDefaults
const sysproxy = {
section: 'settings.sections.system.title',
path: 'settings.modals.sysproxy.title',
dialog: 'sysproxy-viewer',
fallback: 'settings.modals.sysproxy.fields.usePacMode',
prerequisite: 'sysproxy-status',
aliases: '代理 PAC bypass 绕过',
} satisfies GroupDefaults
const clash = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.title',
} satisfies GroupDefaults
const networkInterface = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.title',
dialog: 'network-interface-viewer',
aliases: 'network interface viewer',
} satisfies GroupDefaults
const dns = {
section: 'settings.sections.clash.title',
path: 'settings.modals.dns.dialog.title',
dialog: 'dns-viewer',
fallback: 'dns-mode',
aliases: 'DNS 域名解析 DNS 域名解析',
} satisfies GroupDefaults
const dnsReset = {
section: 'settings.sections.clash.title',
path: 'settings.modals.dns.dialog.title',
dialog: 'dns-viewer',
aliases: 'dns viewer reset DNS 域名解析',
} satisfies GroupDefaults
const clashPort = {
section: 'settings.sections.clash.title',
path: 'settings.modals.clashPort.title',
dialog: 'clash-port-viewer',
} satisfies GroupDefaults
const controller = {
section: 'settings.sections.clash.title',
path: 'settings.sections.externalController.title',
dialog: 'controller-viewer',
} satisfies GroupDefaults
const externalControllerCors = {
section: 'settings.sections.clash.title',
path: 'settings.sections.externalCors.title',
dialog: 'external-controller-cors',
} satisfies GroupDefaults
const webUi = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.title',
dialog: 'web-ui-viewer',
aliases: 'web ui viewer',
} satisfies GroupDefaults
const webUiActions = {
section: 'settings.sections.clash.title',
path: 'settings.modals.webUI.title',
dialog: 'web-ui-viewer',
fallback: 'web-ui-viewer',
} satisfies GroupDefaults
const clashCore = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.form.fields.clashCore',
dialog: 'clash-core-viewer',
fallback: 'core-variants',
} satisfies GroupDefaults
const coreVariants = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.title',
dialog: 'clash-core-viewer',
aliases: 'core variants',
} satisfies GroupDefaults
const tunnels = {
section: 'settings.sections.clash.title',
path: 'settings.sections.clash.form.fields.tunnels.title',
dialog: 'tunnels-viewer',
fallback: 'tunnels-expand',
} satisfies GroupDefaults
const basic = {
section: 'settings.components.verge.basic.title',
path: 'settings.components.verge.basic.title',
} satisfies GroupDefaults
const theme = {
section: 'settings.components.verge.basic.title',
path: 'settings.components.verge.theme.title',
dialog: 'theme-viewer',
} satisfies GroupDefaults
const layout = {
section: 'settings.components.verge.basic.title',
path: 'settings.components.verge.layout.title',
dialog: 'layout-viewer',
aliases: 'browse layout 布局 托盘',
} satisfies GroupDefaults
const layoutFields = {
section: 'settings.components.verge.basic.title',
path: 'settings.components.verge.basic.fields.layoutSetting',
dialog: 'layout-viewer',
aliases: 'layout 布局 托盘',
} satisfies GroupDefaults
const misc = {
section: 'settings.components.verge.basic.title',
path: 'settings.modals.misc.title',
dialog: 'misc-viewer',
aliases: 'misc 杂项',
} satisfies GroupDefaults
const hotkey = {
section: 'settings.components.verge.basic.title',
path: 'settings.modals.hotkey.title',
dialog: 'hotkey-viewer',
} satisfies GroupDefaults
const advanced = {
section: 'settings.components.verge.advanced.title',
path: 'settings.components.verge.advanced.title',
} satisfies GroupDefaults
const backup = {
section: 'settings.components.verge.advanced.title',
path: 'settings.modals.backup.title',
dialog: 'backup-viewer',
aliases: 'backup 备份',
} satisfies GroupDefaults
const backupHistory = {
section: 'settings.components.verge.advanced.title',
path: 'settings.modals.backup.title',
dialog: 'backup-history',
fallback: 'backup-history',
} satisfies GroupDefaults
const backupWebdav = {
section: 'settings.components.verge.advanced.title',
path: 'settings.modals.backup.webdav.title',
dialog: 'backup-webdav',
fallback: 'webdav-state',
} satisfies GroupDefaults
const liteMode = {
section: 'settings.components.verge.advanced.title',
path: 'settings.modals.liteMode.title',
dialog: 'lite-mode-viewer',
} satisfies GroupDefaults
const settingsCatalog: readonly SettingEntry[] = [
...group(system, {
'settings.sections.system.toggles.tunMode': 'TUN 虚拟网卡 vpn',
'settings.sections.proxyControl.actions.uninstallService': {
id: 'service-uninstall',
fallback: 'settings.sections.system.toggles.tunMode',
aliases: 'service uninstall',
},
}),
...group(tun, {
'settings.modals.tun.fields.stack': 'stack TUN 虚拟网卡',
'settings.modals.tun.fields.dnsMode': 'dns mode TUN 虚拟网卡',
'settings.modals.tun.fields.device': 'device TUN 虚拟网卡',
'settings.modals.tun.fields.autoRoute': 'auto route TUN 虚拟网卡',
'settings.modals.tun.fields.autoRedirect': {
aliases: 'auto redirect TUN 虚拟网卡',
prerequisite: 'settings.modals.tun.fields.autoRoute',
platforms: ['linux'],
},
'settings.modals.tun.fields.strictRoute': 'strict route TUN 虚拟网卡',
'settings.modals.tun.fields.autoDetectInterface':
'auto detect interface TUN 虚拟网卡',
'settings.modals.tun.fields.dnsHijack': 'dns hijack TUN 虚拟网卡',
'settings.modals.tun.fields.mtu': 'mtu TUN 虚拟网卡',
'settings.modals.tun.fields.routeExcludeAddress': {
aliases: 'route exclude address TUN 虚拟网卡',
prerequisite: 'settings.modals.tun.fields.autoRoute',
},
'shared.actions.resetToDefault': {
id: 'tun-viewer-reset',
aliases: 'tun viewer reset',
},
}),
...group(system, {
'settings.sections.system.toggles.systemProxy': 'system proxy 系统代理',
}),
...group(sysproxy, {
'settings.modals.sysproxy.fields.proxyHost': 'proxy host',
'settings.modals.sysproxy.fields.usePacMode': 'use pac mode',
'settings.modals.sysproxy.fields.proxyGuard': 'proxy guard',
'settings.modals.sysproxy.fields.guardDuration': 'guard duration',
'settings.modals.sysproxy.fields.alwaysUseDefaultBypass':
'always use default bypass',
'settings.modals.sysproxy.fields.enableBypassCheck': 'enable bypass check',
'settings.modals.sysproxy.fields.proxyBypass': 'proxy bypass',
'settings.modals.sysproxy.fields.pacScriptContent': 'pac script content',
'settings.modals.sysproxy.actions.editPac': 'edit pac',
}),
...group(system, {
'settings.sections.system.fields.autoLaunch': 'auto launch ',
'settings.sections.system.fields.silentStart': 'silent start ',
}),
...group(clash, {
'settings.sections.clash.form.fields.allowLan': 'allow lan ',
}),
...group(networkInterface, {
'settings.modals.networkInterface.title': {
id: 'network-interface-viewer',
},
}),
...group(clash, {
'settings.sections.clash.form.fields.dnsOverwrite': 'dns overwrite ',
}),
...group(dns, {
'settings.modals.dns.fields.enable': 'enable',
'settings.modals.dns.fields.listen': 'listen',
'settings.modals.dns.fields.enhancedMode': 'enhancedMode',
'settings.modals.dns.fields.fakeIpRange': 'fakeIpRange',
'settings.modals.dns.fields.fakeIpRange6': 'fakeIpRange6',
'settings.modals.dns.fields.fakeIpFilterMode': 'fakeIpFilterMode',
'settings.modals.dns.fields.ipv6.label': 'label',
'settings.modals.dns.fields.preferH3.label': 'label',
'settings.modals.dns.fields.respectRules.label': 'label',
'settings.modals.dns.fields.useHosts.label': 'label',
'settings.modals.dns.fields.useSystemHosts.label': 'label',
'settings.modals.dns.fields.directPolicy.label': 'label',
'settings.modals.dns.fields.defaultNameserver.label': 'label',
'settings.modals.dns.fields.nameserver.label': 'label',
'settings.modals.dns.fields.fallback.label': 'label',
'settings.modals.dns.fields.proxy.label': 'label',
'settings.modals.dns.fields.directNameserver.label': 'label',
'settings.modals.dns.fields.fakeIpFilter.label': 'label',
'settings.modals.dns.fields.nameserverPolicy.label': 'label',
'settings.modals.dns.fields.geoipFiltering.label': 'label',
'settings.modals.dns.fields.geoipCode': 'geoipCode',
'settings.modals.dns.fields.fallbackIpCidr.label': 'label',
'settings.modals.dns.fields.fallbackDomain.label': 'label',
'settings.modals.dns.fields.hosts.label': 'label',
}),
...group(dnsReset, {
'shared.actions.resetToDefault': {
id: 'dns-viewer-reset',
},
}),
...group(clash, {
'settings.sections.clash.form.fields.ipv6': 'ipv6 ',
'settings.sections.clash.form.fields.unifiedDelay': 'unified delay ',
'settings.sections.clash.form.fields.logLevel': 'log level ',
'settings.sections.clash.form.fields.portConfig': 'port config ',
}),
...group(clashPort, {
'settings.modals.clashPort.fields.socks': 'clash port viewer',
'settings.modals.clashPort.fields.http': 'clash port viewer',
'settings.modals.clashPort.fields.redir': {
aliases: 'clash port viewer',
platforms: ['macos', 'linux'],
},
'settings.modals.clashPort.fields.tproxy': {
aliases: 'clash port viewer',
platforms: ['linux'],
},
'settings.modals.clashPort.fields.mixed': 'clash port viewer',
'settings.modals.clashPort.actions.random': {
id: 'port-random',
aliases: 'port random',
},
}),
...group(clash, {
'settings.sections.clash.form.fields.external': 'external ',
}),
...group(controller, {
'settings.sections.externalController.fields.enable': 'enable ',
'settings.sections.externalController.fields.address': {
aliases: 'address ',
prerequisite: 'settings.sections.externalController.fields.enable',
},
'settings.sections.externalController.fields.secret': {
aliases: 'secret ',
prerequisite: 'settings.sections.externalController.fields.enable',
},
}),
...group(externalControllerCors, {
'settings.sections.externalCors.fields.allowPrivateNetwork':
'allow private network ',
'settings.sections.externalCors.fields.allowedOrigins': 'allowed origins ',
'settings.sections.externalCors.actions.add': 'add ',
}),
...group(clash, {
'settings.sections.clash.form.fields.webUI': 'web ui ',
}),
...group(webUi, {
'settings.modals.webUI.title': {
id: 'web-ui-viewer',
},
}),
...group(webUiActions, {
'shared.actions.new': {
id: 'web-ui-new',
aliases: 'web ui new',
},
'shared.actions.edit': {
id: 'web-ui-edit',
aliases: 'web ui edit',
},
'shared.actions.delete': {
id: 'web-ui-delete',
aliases: 'web ui delete',
},
'settings.modals.webUI.actions.openUrl': {
id: 'web-ui-open',
aliases: 'web ui open',
},
}),
...group(clash, {
'settings.sections.clash.form.fields.clashCore': 'clash core ',
}),
...group(clashCore, {
'shared.actions.upgrade': {
id: 'core-upgrade',
aliases: 'core upgrade',
},
'shared.actions.restart': {
id: 'core-restart',
aliases: 'core restart',
},
}),
...group(coreVariants, {
'settings.sections.clash.form.fields.clashCore': {
id: 'core-variants',
},
}),
...group(clash, {
'settings.sections.clash.form.fields.openUwpTool': {
aliases: 'open uwp tool ',
platforms: ['windows'],
},
'settings.sections.clash.form.fields.updateGeoData': 'update geo data ',
'settings.sections.clash.form.fields.tunnels.title': 'title ',
}),
...group(tunnels, {
'settings.sections.clash.form.fields.tunnels.existing': 'existing ',
'settings.sections.clash.form.fields.tunnels.default': 'default ',
'settings.sections.clash.form.fields.tunnels.protocols': 'protocols ',
'settings.sections.clash.form.fields.tunnels.localAddr': 'local addr ',
'settings.sections.clash.form.fields.tunnels.localPort': 'local port ',
'settings.sections.clash.form.fields.tunnels.targetAddr': 'target addr ',
'settings.sections.clash.form.fields.tunnels.targetPort': 'target port ',
'settings.sections.clash.form.fields.tunnels.proxyGroup': 'proxy group ',
'settings.sections.clash.form.fields.tunnels.proxyNode': 'proxy node ',
'settings.sections.clash.form.fields.tunnels.actions.add': 'add ',
}),
...group(basic, {
'settings.components.verge.basic.fields.language': 'language ',
'settings.components.verge.basic.fields.themeMode': 'theme mode ',
'settings.components.verge.basic.fields.trayClickEvent': {
aliases: 'tray click event ',
platforms: ['windows', 'macos'],
},
'settings.components.verge.basic.fields.copyEnvType': 'copy env type ',
'settings.components.verge.basic.fields.startPage': 'start page ',
'settings.components.verge.basic.fields.startupScript': 'startup script ',
'settings.components.verge.basic.fields.themeSetting': 'theme setting ',
}),
...group(theme, {
'settings.components.verge.theme.actions.editCss':
'edit css theme 主题 外观',
'settings.components.verge.theme.fields.fontFamily':
'fontFamily theme 主题 外观',
'settings.components.verge.theme.fields.cssInjection':
'cssInjection theme 主题 外观',
'settings.components.verge.theme.fields.primaryColor': 'theme viewer',
'settings.components.verge.theme.fields.secondaryColor': 'theme viewer',
'settings.components.verge.theme.fields.primaryText': 'theme viewer',
'settings.components.verge.theme.fields.secondaryText': 'theme viewer',
'settings.components.verge.theme.fields.infoColor': 'theme viewer',
'settings.components.verge.theme.fields.warningColor': 'theme viewer',
'settings.components.verge.theme.fields.errorColor': 'theme viewer',
'settings.components.verge.theme.fields.successColor': 'theme viewer',
}),
...group(basic, {
'settings.components.verge.basic.fields.layoutSetting': 'layout setting ',
}),
...group(layout, {
'settings.components.verge.basic.actions.browse': '',
}),
...group(layoutFields, {
'settings.components.verge.layout.fields.preferSystemTitlebar':
'preferSystemTitlebar',
'settings.components.verge.layout.fields.trafficGraph': 'trafficGraph',
'settings.components.verge.layout.fields.memoryUsage': 'memoryUsage',
'settings.components.verge.layout.fields.proxyGroupIcon': 'proxyGroupIcon',
'settings.components.verge.layout.fields.pauseRenderTrafficStatsOnBlur':
'pauseRenderTrafficStatsOnBlur',
'settings.components.verge.layout.fields.toastPosition': 'toastPosition',
'settings.components.verge.layout.fields.hoverNavigator': 'hoverNavigator',
'settings.components.verge.layout.fields.hoverNavigatorDelay':
'hoverNavigatorDelay',
'settings.components.verge.layout.fields.navIcon': 'navIcon',
'settings.components.verge.layout.fields.collapseNavBar': 'collapseNavBar',
'settings.components.verge.layout.fields.trayIcon': {
aliases: 'trayIcon',
platforms: ['macos'],
},
'settings.components.verge.layout.fields.enableTraySpeed': {
aliases: 'enableTraySpeed',
platforms: ['macos'],
},
'settings.components.verge.layout.fields.proxyGroupsDisplayMode':
'proxyGroupsDisplayMode',
'settings.components.verge.layout.fields.showOutboundModesInline':
'showOutboundModesInline',
'settings.components.verge.layout.fields.commonTrayIcon': 'commonTrayIcon',
'settings.components.verge.layout.fields.systemProxyTrayIcon':
'systemProxyTrayIcon',
'settings.components.verge.layout.fields.tunTrayIcon': 'tunTrayIcon',
}),
...group(basic, {
'settings.components.verge.basic.fields.misc': 'misc ',
}),
...group(misc, {
'settings.modals.misc.fields.appLogLevel': 'app log level',
'settings.modals.misc.fields.appLogMaxSize': 'app log max size',
'settings.modals.misc.fields.appLogMaxCount': 'app log max count',
'settings.modals.misc.fields.autoCloseConnections':
'auto close connections',
'settings.modals.misc.fields.autoCheckUpdate': 'auto check update',
'settings.modals.misc.fields.enableBuiltinEnhanced':
'enable builtin enhanced',
'settings.modals.misc.fields.proxyLayoutColumns': 'proxy layout columns',
'settings.modals.misc.fields.autoLogClean': 'auto log clean',
'settings.modals.misc.fields.autoDelayDetection': 'auto delay detection',
'settings.modals.misc.fields.autoDelayDetectionInterval': {
aliases: 'auto delay detection interval',
prerequisite: 'settings.modals.misc.fields.autoDelayDetection',
},
'settings.modals.misc.fields.defaultLatencyTest': 'default latency test',
'settings.modals.misc.fields.defaultLatencyTimeout':
'default latency timeout',
}),
...group(basic, {
'settings.components.verge.basic.fields.hotkeySetting': 'hotkey setting ',
}),
...group(hotkey, {
'settings.modals.hotkey.functions.openOrCloseDashboard': 'hotkey viewer',
'settings.modals.hotkey.functions.rule': 'hotkey viewer',
'settings.modals.hotkey.functions.global': 'hotkey viewer',
'settings.modals.hotkey.functions.direct': 'hotkey viewer',
'settings.modals.hotkey.functions.toggleSystemProxy': 'hotkey viewer',
'settings.modals.hotkey.functions.toggleTunMode': 'hotkey viewer',
'settings.modals.hotkey.functions.entryLightweightMode': 'hotkey viewer',
'settings.modals.hotkey.functions.reactivateProfiles': 'hotkey viewer',
'settings.modals.hotkey.toggles.enableGlobal':
'settings.modals.hotkey.toggles.enableGlobal',
}),
...group(advanced, {
'settings.components.verge.advanced.fields.backupSetting':
'backup setting ',
}),
...group(backup, {
'settings.modals.backup.actions.backup': 'backup',
}),
...group(backupHistory, {
'settings.modals.backup.actions.viewHistory': {
aliases: 'view history backup 备份',
target: 'backup-history',
},
}),
...group(backup, {
'settings.modals.backup.actions.importBackup': 'import backup',
}),
...group(backupWebdav, {
'settings.modals.backup.fields.webdavUrl': 'webdavUrl WebDAV 云备份',
'settings.modals.backup.fields.username': 'username WebDAV 云备份',
}),
...group(backup, {
'settings.modals.backup.auto.scheduleLabel': 'scheduleLabel',
'settings.modals.backup.auto.intervalLabel': {
aliases: 'intervalLabel',
prerequisite: 'settings.modals.backup.auto.scheduleLabel',
},
'settings.modals.backup.auto.changeLabel': 'changeLabel',
}),
...group(backupHistory, {
'settings.modals.backup.history.title': {
aliases: '备份 历史 恢复 删除 restore delete history',
target: 'backup-history',
},
}),
...group(backupWebdav, {
'shared.labels.password': {
id: 'webdav-password',
aliases: 'webdav password',
},
'shared.actions.save': {
id: 'webdav-save',
aliases: 'webdav save',
},
'settings.modals.backup.actions.backup': {
id: 'webdav-backup',
aliases: 'webdav backup',
},
'shared.actions.refresh': {
id: 'webdav-refresh',
aliases: 'webdav refresh',
},
}),
...group(backupHistory, {
'settings.modals.backup.actions.deleteBackup': {
id: 'backup-history-delete',
aliases: 'backup history delete',
path: 'settings.modals.backup.history.title',
},
'settings.modals.backup.actions.restoreBackup': {
id: 'backup-history-restore',
aliases: 'backup history restore',
path: 'settings.modals.backup.history.title',
},
'settings.modals.backup.actions.exportBackup': {
id: 'backup-history-export',
aliases: 'backup history export',
path: 'settings.modals.backup.history.title',
},
}),
...group(advanced, {
'settings.components.verge.advanced.fields.runtimeConfig':
'runtime config ',
'settings.components.verge.advanced.fields.openConfDir': 'open conf dir ',
'settings.components.verge.advanced.fields.openCoreDir': 'open core dir ',
'settings.components.verge.advanced.fields.openLogsDir': 'open logs dir ',
'settings.components.verge.advanced.fields.checkUpdates': 'check updates ',
'settings.components.verge.advanced.fields.openDevTools': 'open dev tools ',
'settings.components.verge.advanced.fields.liteModeSettings':
'lite mode settings ',
}),
...group(liteMode, {
'settings.modals.liteMode.actions.enterNow': 'enter now ',
'settings.modals.liteMode.toggles.autoEnter': 'auto enter ',
'settings.modals.liteMode.fields.delay': {
fallback: 'settings.modals.liteMode.toggles.autoEnter',
aliases: 'delay ',
},
}),
...group(advanced, {
'settings.components.verge.advanced.fields.exit': 'exit ',
'settings.components.verge.advanced.fields.exportDiagnostics':
'export diagnostics ',
'settings.components.verge.advanced.fields.vergeVersion': 'verge version ',
}),
]
export function searchSettings(
query: string,
platform: string,
translate: (key: TranslationKey) => string,
english: (key: TranslationKey) => string,
) {
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return []
return settingsCatalog
.filter((e) => !e.platforms || e.platforms.includes(platform))
.map((entry, order) => {
const names = (
translate(entry.name) +
' ' +
english(entry.name)
).toLocaleLowerCase()
const other = (
translate(entry.section) +
' ' +
english(entry.section) +
' ' +
entry.aliases +
' ' +
translate(entry.path) +
' ' +
english(entry.path)
).toLocaleLowerCase()
const score = words.every(
(word) => names.includes(word) || other.includes(word),
)
? words.reduce((sum, word) => sum + (names.includes(word) ? 1 : 0), 0)
: -1
return { entry, order, score }
})
.filter((e) => e.score >= 0)
.sort((a, b) => b.score - a.score || a.order - b.order)
.map((e) => e.entry)
}
@@ -0,0 +1,42 @@
import {
createContext,
use,
useEffect,
useImperativeHandle,
type Ref,
} from 'react'
import type { DialogRef } from '@/components/base'
export interface SearchDialog {
id: string
open: () => void
isOpen: boolean
}
export const SettingsSearchContext = createContext<Set<SearchDialog> | null>(
null,
)
export function useSettingDialog(
ref: Ref<DialogRef> | undefined,
id: string,
open: boolean,
create: () => DialogRef,
) {
useImperativeHandle(ref, create)
const registry = use(SettingsSearchContext)
useEffect(() => {
if (!registry) return
const handle = {
id,
isOpen: open,
open: () => {
if (!open) create().open()
},
}
registry.add(handle)
return () => {
registry.delete(handle)
}
})
}
+317
View File
@@ -0,0 +1,317 @@
import { CloseRounded } from '@mui/icons-material'
import {
GlobalStyles,
Box,
Button,
IconButton,
List,
ListItemButton,
ListItemText,
TextField,
Typography,
} from '@mui/material'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import getSystem from '@/utils/get-system'
import { searchSettings, type SettingEntry } from './settings-catalog'
import {
SettingsSearchContext,
type SearchDialog,
} from './settings-search-context'
export function SettingsSearch({
children,
}: {
children: (
header: ReactNode,
results: ReactNode,
hidden: boolean,
) => ReactNode
}) {
const { t } = useTranslation()
const [registry] = useState(() => new Set<SearchDialog>())
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(0)
const [showPage, setShowPage] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const originalScrollRef = useRef(0)
const resultScrollRef = useRef(0)
const cancelLocateRef = useRef<() => void>(() => {})
const results = searchSettings(
query,
getSystem(),
(key) => t(key),
(key) => t(key, { lng: 'en' }),
)
const searching = !!query.trim()
const scroller = () =>
rootRef.current?.querySelector('.base-container > section')
const restoreResults = () => {
cancelLocateRef.current()
setShowPage(false)
requestAnimationFrame(() => {
scroller()?.scrollTo({ top: resultScrollRef.current })
inputRef.current?.focus()
})
}
const changeQuery = (value: string) => {
cancelLocateRef.current()
if (!searching && value.trim())
originalScrollRef.current = scroller()?.scrollTop ?? 0
setQuery(value)
setSelected(0)
setShowPage(false)
resultScrollRef.current = 0
requestAnimationFrame(() =>
scroller()?.scrollTo({
top: value.trim() ? 0 : originalScrollRef.current,
}),
)
}
const locate = (entry: SettingEntry) => {
cancelLocateRef.current()
if (!showPage) resultScrollRef.current = scroller()?.scrollTop ?? 0
if (entry.dialog) {
const handles = [...registry].filter(
(handle) => handle.id === entry.dialog,
)
;(handles.find((handle) => handle.isOpen) ?? handles[0])?.open()
} else setShowPage(true)
let highlighted: HTMLElement | null = null
let stopped = false
let sawScope = false
let temporaryTabIndex = false
const cleanup = () => {
stopped = true
observer.disconnect()
clearTimeout(timeout)
highlighted?.removeAttribute('data-search-highlight')
if (temporaryTabIndex) highlighted?.removeAttribute('tabindex')
}
const find = (timedOut = false) => {
if (stopped) return
const marker = entry.dialog
? [
...document.querySelectorAll(
`[data-settings-dialog="${entry.dialog}"]`,
),
].find(
(marker) =>
marker.closest('[role="dialog"]')?.getClientRects().length,
)
: rootRef.current
const scope = entry.dialog ? marker?.closest('[role="dialog"]') : marker
if (!scope) {
if (sawScope) {
cleanup()
inputRef.current?.focus()
}
return
}
if (highlighted) return
sawScope = true
const pending = marker?.getAttribute('data-search-ready') === 'false'
if (pending && !timedOut) return
let target = pending
? (scope.querySelector<HTMLElement>('[data-settings-loading]') ??
(marker instanceof HTMLElement ? marker : null))
: scope.querySelector<HTMLElement>(
`[data-setting-target="${entry.target ?? entry.id}"]`,
)
const fields = target
? [
...target.querySelectorAll<HTMLElement>(
'input,textarea,select,[role="combobox"]',
),
]
: []
const disabled =
target?.matches(':disabled,[aria-disabled="true"]') ||
(fields.length > 0 &&
fields.every((field) =>
field.matches(':disabled,[aria-disabled="true"]'),
))
let fallback = pending
if (!target || !target.getClientRects().length || disabled) {
const fallbackId = disabled
? (entry.prerequisite ?? entry.fallback)
: entry.fallback
if (fallbackId) {
target = scope.querySelector<HTMLElement>(
`[data-setting-target="${fallbackId}"]`,
)
fallback = !disabled || !entry.prerequisite
}
}
if (!target || !target.getClientRects().length) return
highlighted = target
target.setAttribute('data-search-highlight', 'true')
target.scrollIntoView({ block: 'center' })
const targetFocusable = target.matches(
'button,input,textarea,select,a[href],[tabindex]',
)
const control =
!fallback && !targetFocusable
? [
...target.querySelectorAll<HTMLElement>(
'input:not(:disabled),textarea:not(:disabled),button:not(:disabled),[role="combobox"],a[href]',
),
].find(
(element) =>
element.getClientRects().length &&
element.getAttribute('aria-disabled') !== 'true',
)
: null
temporaryTabIndex = !control && !targetFocusable
if (temporaryTabIndex) target.tabIndex = -1
;(control ?? target).focus({ preventScroll: true })
}
const observer = new MutationObserver(() => find())
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'data-search-ready', 'disabled'],
})
const timeout = setTimeout(() => {
if (!highlighted) {
find(true)
if (!highlighted) cleanup()
}
}, 5000)
cancelLocateRef.current = cleanup
requestAnimationFrame(() => find())
}
useEffect(() => () => cancelLocateRef.current(), [])
const moveSelection = (
from: number,
direction: number,
focusResult = false,
) => {
const next = (from + direction + results.length) % results.length
setSelected(next)
const target = rootRef.current?.querySelector<HTMLElement>(
`[data-search-result="${next}"]`,
)
if (focusResult) target?.focus()
target?.scrollIntoView({ block: 'nearest' })
}
const header = (
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<TextField
inputRef={inputRef}
size="small"
label={t('settings.search.placeholder')}
value={query}
onChange={(e) => changeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (showPage) restoreResults()
else changeQuery('')
}
if (e.key === 'Enter' && results[selected]) {
e.preventDefault()
locate(results[selected])
}
if (
(e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
results.length
) {
e.preventDefault()
moveSelection(selected, e.key === 'ArrowDown' ? 1 : -1)
}
}}
/>
{searching && (
<IconButton
aria-label={t('settings.search.clear')}
onClick={() => changeQuery('')}
>
<CloseRounded />
</IconButton>
)}
{showPage && (
<Button onClick={restoreResults}>{t('settings.search.back')}</Button>
)}
</Box>
)
const resultView =
searching && !showPage ? (
<List aria-label={t('settings.search.results')}>
{results.length ? (
results.map((entry, index) => (
<ListItemButton
key={entry.id}
data-search-result={index}
selected={selected === index}
onFocus={() => setSelected(index)}
onClick={() => {
setSelected(index)
locate(entry)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
moveSelection(index, e.key === 'ArrowDown' ? 1 : -1, true)
}
if (e.key === 'Escape') {
e.preventDefault()
inputRef.current?.focus()
}
}}
>
<ListItemText
primary={t(entry.name)}
secondary={`${t(entry.section)} / ${t(entry.path)}`}
/>
</ListItemButton>
))
) : (
<Typography sx={{ p: 2 }}>{t('settings.search.empty')}</Typography>
)}
</List>
) : null
return (
<SettingsSearchContext value={registry}>
<GlobalStyles
styles={{
'[data-search-highlight]': {
outline: '2px solid #6b70e8',
outlineOffset: -2,
},
}}
/>
<Box
ref={rootRef}
onKeyDown={(e) => {
if (
e.key === 'Escape' &&
showPage &&
!(e.target as HTMLElement).closest('[role="dialog"]')
) {
e.stopPropagation()
restoreResults()
}
}}
sx={{
height: '100%',
'& .base-page > header': {
flex: '0 0 auto',
minHeight: 58,
flexWrap: 'wrap',
gap: 1,
py: 1,
},
}}
>
{children(header, resultView, searching && !showPage)}
</Box>
</SettingsSearchContext>
)
}
@@ -220,6 +220,7 @@ const ProxyControlSwitches = ({
)} )}
icon={DeleteForeverRounded} icon={DeleteForeverRounded}
color="secondary" color="secondary"
data-setting-target="service-uninstall"
onClick={onUninstallService} onClick={onUninstallService}
sx={{ ml: 1 }} sx={{ ml: 1 }}
/> />
+18 -4
View File
@@ -7,6 +7,10 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base' import { BaseDialog } from '@/components/base'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import type {
IVergeTestItem_Deserialize,
IVergeTestItem_Serialize,
} from '@/services/bindings'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
interface Props { interface Props {
@@ -27,7 +31,7 @@ export const TestViewer = forwardRef<TestViewerRef, Props>(
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { verge, patchVerge } = useVerge() const { verge, patchVerge } = useVerge()
const testList = verge?.test_list ?? [] const testList = verge?.test_list ?? []
const { control, ...formIns } = useForm<IVergeTestItem>({ const { control, ...formIns } = useForm<IVergeTestItem_Deserialize>({
defaultValues: { defaultValues: {
name: '', name: '',
icon: '', icon: '',
@@ -37,11 +41,16 @@ export const TestViewer = forwardRef<TestViewerRef, Props>(
const patchTestList = async ( const patchTestList = async (
uid: string, uid: string,
patch: Partial<IVergeTestItem>, patch: Partial<IVergeTestItem_Deserialize>,
) => { ) => {
const newList = testList.map((x) => { const newList = testList.map((x) => {
if (x.uid === uid) { if (x.uid === uid) {
return { ...x, ...patch } return {
uid: x.uid,
name: patch.name === undefined ? x.name : patch.name,
icon: patch.icon === undefined ? x.icon : patch.icon,
url: patch.url === undefined ? x.url : patch.url,
}
} }
return x return x
}) })
@@ -77,7 +86,12 @@ export const TestViewer = forwardRef<TestViewerRef, Props>(
if (openType === 'new') { if (openType === 'new') {
const uid = nanoid() const uid = nanoid()
const item = { ...form, uid } const item: IVergeTestItem_Serialize = {
uid,
name: form.name ?? null,
icon: form.icon ?? null,
url: form.url ?? null,
}
const newList = [...testList, item] const newList = [...testList, item]
await patchVerge({ test_list: newList }) await patchVerge({ test_list: newList })
onChange(uid) onChange(uid)
+6 -1
View File
@@ -17,7 +17,12 @@ const PRESET_PROXY_NAMES = [
'COMPATIBLE', 'COMPATIBLE',
] ]
const identity = (_: DelayUpdate, next: DelayUpdate): DelayUpdate => next const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
previous.delay === next.delay &&
previous.elapsed === next.elapsed &&
previous.updatedAt === next.updatedAt
? previous
: next
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 } const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
+7
View File
@@ -714,5 +714,12 @@
"installing": "جاري تثبيت الخدمة...", "installing": "جاري تثبيت الخدمة...",
"uninstalling": "Uninstalling Service..." "uninstalling": "Uninstalling Service..."
} }
},
"search": {
"placeholder": "البحث في الإعدادات",
"clear": "مسح البحث",
"back": "العودة إلى النتائج",
"results": "نتائج البحث",
"empty": "لم يتم العثور على إعدادات"
} }
} }
+7
View File
@@ -714,5 +714,12 @@
"installing": "Service wird installiert...", "installing": "Service wird installiert...",
"uninstalling": "Service wird deinstalliert..." "uninstalling": "Service wird deinstalliert..."
} }
},
"search": {
"placeholder": "Einstellungen suchen",
"clear": "Suche löschen",
"back": "Zurück zu Ergebnissen",
"results": "Suchergebnisse",
"empty": "Keine Einstellungen gefunden"
} }
} }
+7
View File
@@ -714,5 +714,12 @@
"installing": "Installing Service...", "installing": "Installing Service...",
"uninstalling": "Uninstalling Service..." "uninstalling": "Uninstalling Service..."
} }
},
"search": {
"placeholder": "Search settings",
"clear": "Clear search",
"back": "Back to results",
"results": "Search results",
"empty": "No settings found"
} }
} }
+7
View File
@@ -714,5 +714,12 @@
"installing": "Instalando servicio...", "installing": "Instalando servicio...",
"uninstalling": "Desinstalando servicio..." "uninstalling": "Desinstalando servicio..."
} }
},
"search": {
"placeholder": "Buscar ajustes",
"clear": "Borrar búsqueda",
"back": "Volver a resultados",
"results": "Resultados de búsqueda",
"empty": "No se encontraron ajustes"
} }
} }

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