Compare commits

..
Author SHA1 Message Date
oomeow da050d3e7e docs: update Changelog.md 2026-10-13 19:55:45 +08:00
oomeow 9a7b15eb15 fix(setting): default system titlebar to enabled if undefined 2026-10-12 21:17:53 +08:00
oomeow 98072a17a3 feat(bindings): add enable_system_titlebar option to Verge types 2026-10-12 21:16:06 +08:00
oomeow 32a093e44b Merge remote-tracking branch 'origin/dev' into refactor/custom-titlebar 2026-10-12 20:59:57 +08:00
oomeow 7ecec42f4d feat(window): add persistent system titlebar setting
- persist enable_system_titlebar in verge config and apply it to window decorations
- replace window provider/context with useWindowMaximized hook and inline controls
- add i18n field and tooltip for the setting across locales
2026-10-12 20:05:02 +08:00
119 changed files with 2407 additions and 1335 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:2": { "ghcr.io/devcontainers/features/node:2": {
"version": "24.22.0", "version": "24.21.0",
"pnpmVersion": "12.14.0" "pnpmVersion": "12.11.2"
}, },
"ghcr.io/devcontainers/features/rust:1": { "ghcr.io/devcontainers/features/rust:1": {
"version": "1.99.1", "version": "1.99.0",
"profile": "default" "profile": "default"
}, },
"ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/git:1": {},
+10 -10
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -154,7 +154,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -185,7 +185,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -206,7 +206,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -257,7 +257,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -282,7 +282,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -347,7 +347,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -426,7 +426,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -454,7 +454,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -524,7 +524,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6.1.0 - uses: pnpm/action-setup@v6.1.0
name: Install pnpm name: Install pnpm
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: dtolnay/rust-toolchain@1.99.1 uses: dtolnay/rust-toolchain@1.99.0
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -103,7 +103,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
@@ -129,7 +129,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
run: | run: |
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed # Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- name: Restore pnpm cache - name: Restore pnpm cache
@@ -138,7 +138,7 @@ jobs:
- uses: actions/setup-node@v7 - uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true' if: steps.check_inputs.outputs.contract == 'true'
with: with:
node-version: '24.22.0' node-version: '24.21.0'
cache: 'pnpm' cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
+12 -12
View File
@@ -204,7 +204,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -230,7 +230,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -244,7 +244,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -311,7 +311,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -331,7 +331,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
@@ -389,7 +389,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -446,7 +446,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -466,7 +466,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -487,7 +487,7 @@ jobs:
- name: Add Rust Target - name: Add Rust Target
run: | run: |
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }} rustup target add ${{ matrix.target }} --toolchain 1.99.0 || rustup target add ${{ matrix.target }}
rustup target list --installed rustup target list --installed
echo "Rust target ${{ matrix.target }} installed." echo "Rust target ${{ matrix.target }} installed."
@@ -547,7 +547,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -573,7 +573,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -638,7 +638,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -22,13 +22,13 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: '1.99.1' toolchain: '1.99.0'
targets: x86_64-pc-windows-msvc targets: x86_64-pc-windows-msvc
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v7 uses: actions/setup-node@v7
with: with:
node-version: '24.22.0' node-version: '24.21.0'
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
name: Install pnpm name: Install pnpm
+3 -3
View File
@@ -1,3 +1,3 @@
nodejs 24.22.0 nodejs 24.21.0
pnpm 12.14.0 pnpm 12.11.2
rust 1.99.1 rust 1.99.0
Generated
+31 -34
View File
@@ -1138,7 +1138,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng", "serde_yaml_ng",
"sha2 0.11.1", "sha2 0.11.0",
"smartstring", "smartstring",
"socket2", "socket2",
"specta", "specta",
@@ -1175,7 +1175,7 @@ dependencies = [
"windows-service", "windows-service",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winreg 0.56.0", "winreg 0.56.0",
"zip 9.0.4", "zip 9.0.1",
] ]
[[package]] [[package]]
@@ -1247,7 +1247,7 @@ dependencies = [
"once_cell", "once_cell",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.1", "sha2 0.11.0",
"strum", "strum",
"strum_macros", "strum_macros",
"sysproxy", "sysproxy",
@@ -1462,12 +1462,9 @@ dependencies = [
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.6.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 = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cookie" name = "cookie"
@@ -3954,9 +3951,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.191" version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]] [[package]]
name = "libdbus-sys" name = "libdbus-sys"
@@ -4051,11 +4048,11 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lzma-rust2" name = "lzma-rust2"
version = "0.22.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
dependencies = [ dependencies = [
"sha2 0.11.1", "sha2 0.11.0",
] ]
[[package]] [[package]]
@@ -5346,9 +5343,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppmd-rust" name = "ppmd-rust"
version = "1.5.0" version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1" checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
@@ -6070,9 +6067,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.46" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -6413,9 +6410,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.154" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -6600,9 +6597,9 @@ dependencies = [
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.11.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
@@ -7495,7 +7492,7 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-mihomo" name = "tauri-plugin-mihomo"
version = "0.6.0" version = "0.6.0"
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?rev=7a03d16f4d39b1bb885b7babe66b9e153e34b615#7a03d16f4d39b1bb885b7babe66b9e153e34b615" source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?branch=main#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"clashmap", "clashmap",
@@ -8076,9 +8073,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.3" version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -8138,9 +8135,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.20" version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
@@ -10153,9 +10150,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "9.0.4" version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22" checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [ dependencies = [
"aes", "aes",
"bzip2", "bzip2",
@@ -10205,27 +10202,27 @@ dependencies = [
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.14.3" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [ dependencies = [
"zstd-safe", "zstd-safe",
] ]
[[package]] [[package]]
name = "zstd-safe" name = "zstd-safe"
version = "8.2.0" version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [ dependencies = [
"zstd-sys", "zstd-sys",
] ]
[[package]] [[package]]
name = "zstd-sys" name = "zstd-sys"
version = "2.1.2+zstd.1.5.7" version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
+2 -2
View File
@@ -67,7 +67,7 @@ parking_lot = { version = "0.12.5", features = ["hardware-lock-elision"] }
anyhow = "1.0.104" anyhow = "1.0.104"
chrono = "0.4.45" chrono = "0.4.45"
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.53.2", features = [
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
"time", "time",
@@ -88,7 +88,7 @@ rust_iso3166 = "0.2.0"
smartstring = { version = "1.0.1" } 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.151" }
serde_yaml_ng = { version = "0.10.0" } serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.2" } bitflags = { version = "2.13.2" }
socket2 = "0.6.5" socket2 = "0.6.5"
+3 -9
View File
@@ -3,14 +3,8 @@
<details> <details>
<summary><strong> 🐞 修复问题 </strong></summary> <summary><strong> 🐞 修复问题 </strong></summary>
- 修复托盘测速与代理页行为不一致的问题,移除托盘「测试延迟」入口 - 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置的问题
- 修复通过 URL 导入订阅时,即使导入的订阅并非当前使用的订阅,也会重新加载当前配置 - 修复「首页」网站测速失败后卡片一直显示测试中的问题
- 修复 首页 网站测速失败后卡片一直显示测试中
- 修复 代理 页展开分组后不显示最新测速结果,需切换节点才刷新
**🐧 Linux**
- Linux:修复 deb/rpm 安装包错误包含多余的 `frontend-contract` 可执行文件,导致下游重新打包失败的问题
</details> </details>
@@ -23,7 +17,7 @@
<details> <details>
<summary><strong> 🚀 优化改进 </strong></summary> <summary><strong> 🚀 优化改进 </strong></summary>
- 优化「链式代理」:开启前需确认网速影响提示,空面板新增「关闭」按钮 - 记住「启用系统标题栏」设置,Windows/macOS 即时生效,Linux 重启后生效
**🖥️ Windows** **🖥️ Windows**
+2
View File
@@ -70,6 +70,8 @@ tray:
global: عام global: عام
profiles: الملفات التعريفية profiles: الملفات التعريفية
proxies: وكلاء proxies: وكلاء
testLatency: اختبار التأخير
testingLatency: جارٍ اختبار التأخير…
systemProxy: وكيل النظام systemProxy: وكيل النظام
tunMode: وضع TUN tunMode: وضع TUN
closeAllConnections: إغلاق كل الاتصالات closeAllConnections: إغلاق كل الاتصالات
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profile profiles: Profile
proxies: Proxy proxies: Proxy
testLatency: Latenz testen
testingLatency: Latenz wird getestet…
systemProxy: Systemproxy systemProxy: Systemproxy
tunMode: TUN-Modus tunMode: TUN-Modus
closeAllConnections: Alle Verbindungen schließen closeAllConnections: Alle Verbindungen schließen
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profiles profiles: Profiles
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: System Proxy systemProxy: System Proxy
tunMode: TUN Mode tunMode: TUN Mode
closeAllConnections: Close All Connections closeAllConnections: Close All Connections
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Perfiles profiles: Perfiles
proxies: Proxies proxies: Proxies
testLatency: Probar latencia
testingLatency: Probando latencia…
systemProxy: Proxy del sistema systemProxy: Proxy del sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Cerrar todas las conexiones closeAllConnections: Cerrar todas las conexiones
+2
View File
@@ -70,6 +70,8 @@ tray:
global: سراسری global: سراسری
profiles: پروفایل‌ها profiles: پروفایل‌ها
proxies: پروکسی‌ها proxies: پروکسی‌ها
testLatency: آزمایش تأخیر
testingLatency: در حال آزمایش تأخیر…
systemProxy: پروکسی سیستم systemProxy: پروکسی سیستم
tunMode: حالت TUN tunMode: حالت TUN
closeAllConnections: بستن همه اتصال‌ها closeAllConnections: بستن همه اتصال‌ها
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Profil profiles: Profil
proxies: Proksi proxies: Proksi
testLatency: Uji Latensi
testingLatency: Menguji latensi…
systemProxy: Proksi Sistem systemProxy: Proksi Sistem
tunMode: Mode TUN tunMode: Mode TUN
closeAllConnections: Tutup Semua Koneksi closeAllConnections: Tutup Semua Koneksi
+2
View File
@@ -70,6 +70,8 @@ tray:
global: グローバル global: グローバル
profiles: プロファイル profiles: プロファイル
proxies: プロキシ proxies: プロキシ
testLatency: 遅延テスト
testingLatency: 遅延をテスト中…
systemProxy: システムプロキシ systemProxy: システムプロキシ
tunMode: TUN モード tunMode: TUN モード
closeAllConnections: すべての接続を閉じる closeAllConnections: すべての接続を閉じる
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 글로벌 global: 글로벌
profiles: 프로필 profiles: 프로필
proxies: 프록시 proxies: 프록시
testLatency: 지연 테스트
testingLatency: 지연 테스트 중…
systemProxy: 시스템 프록시 systemProxy: 시스템 프록시
tunMode: TUN 모드 tunMode: TUN 모드
closeAllConnections: 모든 연결 닫기 closeAllConnections: 모든 연결 닫기
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Global global: Global
profiles: Perfis profiles: Perfis
proxies: Proxies proxies: Proxies
testLatency: Test Latency
testingLatency: Testing latency…
systemProxy: Proxy do sistema systemProxy: Proxy do sistema
tunMode: Modo TUN tunMode: Modo TUN
closeAllConnections: Fechar todas as conexões closeAllConnections: Fechar todas as conexões
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобальный global: Глобальный
profiles: Профили profiles: Профили
proxies: Прокси proxies: Прокси
testLatency: Проверить задержку
testingLatency: Проверка задержки…
systemProxy: Системный прокси systemProxy: Системный прокси
tunMode: Режим TUN tunMode: Режим TUN
closeAllConnections: Закрыть все соединения closeAllConnections: Закрыть все соединения
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Küresel global: Küresel
profiles: Profiller profiles: Profiller
proxies: Vekil'ler proxies: Vekil'ler
testLatency: Gecikmeyi Test Et
testingLatency: Gecikme test ediliyor…
systemProxy: Sistem Vekil'i systemProxy: Sistem Vekil'i
tunMode: TUN Modu tunMode: TUN Modu
closeAllConnections: Tüm Bağlantıları Kapat closeAllConnections: Tüm Bağlantıları Kapat
+2
View File
@@ -70,6 +70,8 @@ tray:
global: Глобаль global: Глобаль
profiles: Профильләр profiles: Профильләр
proxies: Проксилар proxies: Проксилар
testLatency: Кичектерүне тикшерү
testingLatency: Кичектерү тикшерелә…
systemProxy: Системалы прокси systemProxy: Системалы прокси
tunMode: TUN режимы tunMode: TUN режимы
closeAllConnections: Барлык тоташуларны ябу closeAllConnections: Барлык тоташуларны ябу
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全局 global: 全局
profiles: 订阅 profiles: 订阅
proxies: 代理 proxies: 代理
testLatency: 测试延迟
testingLatency: 正在测试延迟…
systemProxy: 系统代理 systemProxy: 系统代理
tunMode: TUN 模式 tunMode: TUN 模式
closeAllConnections: 关闭所有连接 closeAllConnections: 关闭所有连接
+2
View File
@@ -70,6 +70,8 @@ tray:
global: 全域 global: 全域
profiles: 訂閱 profiles: 訂閱
proxies: 代理 proxies: 代理
testLatency: 測試延遲
testingLatency: 正在測試延遲…
systemProxy: 系統代理 systemProxy: 系統代理
tunMode: 虛擬網路介面卡模式 tunMode: 虛擬網路介面卡模式
closeAllConnections: 關閉所有連線 closeAllConnections: 關閉所有連線
+31 -23
View File
@@ -1,15 +1,3 @@
//! Linux-only termination signal bridge.
//!
//! Upstream Tauri delivers session-end and termination events to the app on
//! Windows and macOS, but has no equivalent on Linux: a desktop session may
//! end without any window or run event reaching the process. Linux builds
//! keep this crate to listen for SIGTERM, SIGINT and SIGHUP and forward them
//! to the app quit flow, so system proxy and TUN cleanup still runs before
//! the process dies. Non-Linux builds compile this crate to nothing and must
//! not depend on it.
#![cfg(target_os = "linux")]
use std::sync::{ use std::sync::{
OnceLock, OnceLock,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -17,13 +5,20 @@ use std::sync::{
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
#[cfg(unix)]
mod unix; mod unix;
#[cfg(windows)]
mod windows;
pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new(); pub(crate) static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new(); pub(crate) static SHUTDOWN_LATCH: ShutdownLatch = ShutdownLatch::new();
/// Serializes signal-triggered cleanups: a signal arriving while a quit #[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// attempt is still in flight is dropped instead of overlapping it. pub enum ShutdownOutcome {
Committed,
Canceled,
}
pub(crate) struct ShutdownLatch { pub(crate) struct ShutdownLatch {
is_cleaning_up: AtomicBool, is_cleaning_up: AtomicBool,
} }
@@ -41,18 +36,17 @@ impl ShutdownLatch {
.is_ok() .is_ok()
} }
fn finish(&self) { fn finish(&self, outcome: ShutdownOutcome) {
self.is_cleaning_up.store(false, Ordering::Release); if matches!(outcome, ShutdownOutcome::Canceled) {
self.is_cleaning_up.store(false, Ordering::Release);
}
} }
} }
/// Spawns the Linux signal loop on a dedicated runtime so it survives the
/// teardown of any app-owned runtime. The handler runs once per accepted
/// signal; the quit flow it invokes owns the exit-or-cancel decision.
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static, Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{ {
RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() { RUNTIME.get_or_init(|| match tokio::runtime::Runtime::new() {
Ok(rt) => Some(rt), Ok(rt) => Some(rt),
@@ -67,22 +61,36 @@ where
} }
}); });
#[cfg(unix)]
unix::register(f); unix::register(f);
#[cfg(windows)]
windows::register(f);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ShutdownLatch; use super::{ShutdownLatch, ShutdownOutcome};
#[test] #[test]
fn finished_attempt_releases_latch_for_the_next_signal() { fn canceled_shutdown_releases_latch_and_permits_next_attempt() {
let latch = ShutdownLatch::new(); let latch = ShutdownLatch::new();
assert!(latch.try_begin()); assert!(latch.try_begin());
assert!(!latch.try_begin()); assert!(!latch.try_begin());
latch.finish(); latch.finish(ShutdownOutcome::Canceled);
assert!(latch.try_begin()); assert!(latch.try_begin());
} }
#[test]
fn committed_shutdown_retains_latch_and_suppresses_repeats() {
let latch = ShutdownLatch::new();
assert!(latch.try_begin());
latch.finish(ShutdownOutcome::Committed);
assert!(!latch.try_begin());
}
} }
+3 -4
View File
@@ -1,12 +1,12 @@
use clash_verge_logging::{Type, logging}; use clash_verge_logging::{Type, logging};
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
use crate::{RUNTIME, SHUTDOWN_LATCH}; use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F) pub fn register<F, Fut>(f: F)
where where
F: Fn() -> Fut + Send + Sync + 'static, F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static, Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{ {
if let Some(Some(rt)) = RUNTIME.get() { if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move { rt.spawn(async move {
@@ -60,8 +60,7 @@ where
} }
logging!(info, Type::SystemSignal, "Caught signal {}", signal_name); logging!(info, Type::SystemSignal, "Caught signal {}", signal_name);
f().await; SHUTDOWN_LATCH.finish(f().await);
SHUTDOWN_LATCH.finish();
} }
}); });
} else { } else {
+83
View File
@@ -0,0 +1,83 @@
use clash_verge_logging::{Type, logging};
use tokio::signal::windows;
use crate::{RUNTIME, SHUTDOWN_LATCH, ShutdownOutcome};
pub fn register<F, Fut>(f: F)
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = ShutdownOutcome> + Send + 'static,
{
if let Some(Some(rt)) = RUNTIME.get() {
rt.spawn(async move {
let mut ctrl_c = match windows::ctrl_c() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+C: {}", e);
return;
}
};
let mut ctrl_close = match windows::ctrl_close() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Close: {}", e);
return;
}
};
let mut ctrl_shutdown = match windows::ctrl_shutdown() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Shutdown: {}", e);
return;
}
};
let mut ctrl_logoff = match windows::ctrl_logoff() {
Ok(s) => s,
Err(e) => {
logging!(error, Type::SystemSignal, "Failed to register Ctrl+Logoff: {}", e);
return;
}
};
loop {
let signal_name;
tokio::select! {
_ = ctrl_c.recv() => {
signal_name = "Ctrl+C";
}
_ = ctrl_close.recv() => {
signal_name = "Ctrl+Close";
}
_ = ctrl_shutdown.recv() => {
signal_name = "Ctrl+Shutdown";
}
_ = ctrl_logoff.recv() => {
signal_name = "Ctrl+Logoff";
}
}
if !SHUTDOWN_LATCH.try_begin() {
logging!(
info,
Type::SystemSignal,
"Already shutting down, ignoring repeated signal: {}",
signal_name
);
continue;
}
logging!(info, Type::SystemSignal, "Caught Windows signal: {}", signal_name);
SHUTDOWN_LATCH.finish(f().await);
}
});
} else {
logging!(
error,
Type::SystemSignal,
"register shutdown signal failed, RUNTIME is not available"
);
}
}
@@ -15,7 +15,7 @@ serde = { workspace = true, features = ["derive"] }
sysinfo = { version = "0.39.6", features = ["network", "system"] } sysinfo = { version = "0.39.6", features = ["network", "system"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
libc = "0.2.191" libc = "0.2.190"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
deelevate = { workspace = true } deelevate = { workspace = true }
+6 -6
View File
@@ -75,11 +75,11 @@
"monaco-yaml": "^5.5.1", "monaco-yaml": "^5.5.1",
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1", "react-dom": "19.3.0",
"react-error-boundary": "6.1.6", "react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-i18next": "17.0.19", "react-i18next": "17.0.15",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
@@ -123,7 +123,7 @@
"https-proxy-agent": "^9.1.0", "https-proxy-agent": "^9.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"knip": "6.41.0", "knip": "6.39.0",
"lint-staged": "^17.5.1", "lint-staged": "^17.5.1",
"sass": "^1.104.1", "sass": "^1.104.1",
"tar": "^7.5.22", "tar": "^7.5.22",
@@ -133,7 +133,7 @@
"vite": "^8.3.0", "vite": "^8.3.0",
"vite-plugin-svgr": "^5.2.0", "vite-plugin-svgr": "^5.2.0",
"vitest": "^5.0.1", "vitest": "^5.0.1",
"webdriverio": "10.0.2", "webdriverio": "10.0.0",
"ws": "8.22.0" "ws": "8.22.0"
}, },
"lint-staged": { "lint-staged": {
@@ -146,5 +146,5 @@
] ]
}, },
"type": "module", "type": "module",
"packageManager": "pnpm@12.14.0" "packageManager": "pnpm@12.11.2"
} }
+309 -313
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -45,17 +45,6 @@
"matchManagers": ["npm"], "matchManagers": ["npm"],
"groupName": "npm dependencies" "groupName": "npm dependencies"
}, },
{
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
"matchPackageNames": [
"@tauri-apps/**",
"@wdio/tauri-service",
"tauri",
"tauri-*"
],
"groupName": "tauri",
"addLabels": ["tauri"]
},
{ {
"description": "Group all GitHub Actions updates into a single PR", "description": "Group all GitHub Actions updates into a single PR",
"matchManagers": ["github-actions"], "matchManagers": ["github-actions"],
+1 -1
View File
@@ -1,3 +1,3 @@
[toolchain] [toolchain]
channel = "1.99.1" channel = "1.99.0"
components = ["rustfmt", "clippy"] components = ["rustfmt", "clippy"]
+1 -1
View File
@@ -22,7 +22,7 @@ try {
'run', 'run',
'-p', '-p',
'clash-verge', 'clash-verge',
'--example', '--bin',
'frontend-contract', 'frontend-contract',
'--locked', '--locked',
'-q', '-q',
+7 -10
View File
@@ -35,6 +35,7 @@ tauri-build = { version = "2.7.1", features = [] }
clash-verge-draft = { workspace = true } clash-verge-draft = { workspace = true }
clash-verge-media-unlock = { workspace = true } clash-verge-media-unlock = { workspace = true }
clash-verge-logging = { workspace = true } clash-verge-logging = { workspace = true }
clash-verge-signal = { workspace = true }
clash-verge-i18n = { workspace = true } clash-verge-i18n = { workspace = true }
clash-verge-limiter = { workspace = true } clash-verge-limiter = { workspace = true }
tauri-plugin-clash-verge-sysinfo = { workspace = true } tauri-plugin-clash-verge-sysinfo = { workspace = true }
@@ -79,13 +80,13 @@ tauri-plugin-fs = "2.6.0"
tauri-plugin-process = "2.4.0" tauri-plugin-process = "2.4.0"
tauri-plugin-deep-link = "2.6.1" tauri-plugin-deep-link = "2.6.1"
tauri-plugin-window-state = "2.5.0" tauri-plugin-window-state = "2.5.0"
zip = "9.0.4" zip = "9.0.1"
flate2 = "1.1.10" flate2 = "1.1.10"
reqwest_dav = "0.3.3" reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" } aes-gcm = { version = "0.11.1" }
base64 = "0.23.1" base64 = "0.23.1"
minisign-verify = "0.3.0" minisign-verify = "0.3.0"
sha2 = "0.11.1" sha2 = "0.11"
getrandom = "0.4.3" getrandom = "0.4.3"
futures = "0.3.34" futures = "0.3.34"
gethostname = "1.1.0" gethostname = "1.1.0"
@@ -96,28 +97,24 @@ backon = { version = "1.6.0", features = ["tokio-sleep"] }
tauri-plugin-http = "2.8.0" tauri-plugin-http = "2.8.0"
tracing = { workspace = true } tracing = { workspace = true }
tauri-plugin-devtools = { version = "2.2.2", optional = true } tauri-plugin-devtools = { version = "2.2.2", optional = true }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" }
async-trait = "0.1.92" async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [ clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.6", version = "2.7.6", features = [
"client", "client",
] } ] }
arc-swap = "1.9.2" arc-swap = "1.9.2"
tokio-rustls = "0.26.6" tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] } rustls = { version = "0.23.45", features = ["ring"] }
webpki-roots = "1.0.9" webpki-roots = "1.0.9"
dark-light = "3.0.0" dark-light = "3.0.0"
bytes = "1.12.1" bytes = "1.12.1"
tokio-util = { version = "0.7.20", features = ["time"] } tokio-util = { version = "0.7.19", features = ["time"] }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] } specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] }
tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] }
specta-typescript = "=0.0.12" specta-typescript = "=0.0.12"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.191" libc = "0.2.190"
[target.'cfg(target_os = "linux")'.dependencies]
# Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
clash-verge-signal = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true } tauri-plugin-wdio-webdriver = { version = "=1.4.0", optional = true }
@@ -1,5 +1,3 @@
// Dev-only target: Tauri ships everything under src/bin/ to /usr/bin in deb/rpm
// bundles (PR #8131), so this generator must stay an example, never a bin.
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let output = args let output = args
+7 -6
View File
@@ -282,9 +282,7 @@ async fn handle_validation_failure(
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome); logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
discard_and_restore(current_profile).await?; discard_and_restore(current_profile).await?;
if !matches!(outcome, ValidationOutcome::Busy) { handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
}
Ok(outcome) Ok(outcome)
} }
@@ -313,6 +311,9 @@ async fn perform_config_update(
current_value: Option<&String>, current_value: Option<&String>,
current_profile: Option<&String>, current_profile: Option<&String>,
) -> CmdResult<ValidationOutcome> { ) -> CmdResult<ValidationOutcome> {
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await; let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
match update_result { match update_result {
@@ -372,6 +373,8 @@ pub struct ProfilePatch {
#[specta(type = Option<std::string::String>)] #[specta(type = Option<std::string::String>)]
pub url: Option<String>, pub url: Option<String>,
pub selected: Option<Vec<crate::config::PrfSelected>>, pub selected: Option<Vec<crate::config::PrfSelected>>,
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
pub extra: Option<crate::config::PrfExtra>, pub extra: Option<crate::config::PrfExtra>,
pub updated: Option<usize>, pub updated: Option<usize>,
pub option: Option<PrfOption>, pub option: Option<PrfOption>,
@@ -386,6 +389,7 @@ impl From<ProfilePatch> for PrfItem {
file: patch.file, file: patch.file,
url: patch.url, url: patch.url,
selected: patch.selected, selected: patch.selected,
latency_test_urls: patch.latency_test_urls,
extra: patch.extra, extra: patch.extra,
updated: patch.updated, updated: patch.updated,
option: patch.option, option: patch.option,
@@ -405,9 +409,6 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求"); logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
return Ok(ValidationOutcome::Busy); return Ok(ValidationOutcome::Busy);
} }
defer! {
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
}
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
let profiles = IProfiles { let profiles = IProfiles {
+14 -2
View File
@@ -108,20 +108,32 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> { pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
proxy_view(None).await
}
pub(crate) async fn proxy_view(provider_timeout: Option<std::time::Duration>) -> CmdResult<ProxyViewV1> {
let runtime = Config::runtime().await; let runtime = Config::runtime().await;
let latest_runtime = runtime.latest_arc(); let latest_runtime = runtime.latest_arc();
let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref()); let runtime_group_order = runtime_group_order(latest_runtime.config.as_ref());
let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref()); let group_scopes = runtime_group_scopes(latest_runtime.config.as_ref());
let mihomo = Handle::mihomo(); let mihomo = Handle::mihomo();
let (proxies, providers) = tokio::join!(mihomo.get_proxies(), mihomo.get_proxy_providers(),); let (proxies, providers) = tokio::join!(mihomo.get_proxies(), async {
match provider_timeout {
Some(timeout) => tokio::time::timeout(timeout, mihomo.get_proxy_providers())
.await
.ok()
.and_then(Result::ok),
None => mihomo.get_proxy_providers().await.ok(),
}
});
let proxies = proxies.stringify_err()?; let proxies = proxies.stringify_err()?;
Ok(ProxyViewBuilder::build(ProxyViewInput { Ok(ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order, runtime_group_order,
group_scopes, group_scopes,
proxies, proxies,
providers: providers.ok(), providers,
})) }))
} }
+6
View File
@@ -53,6 +53,10 @@ pub struct PrfItem {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub selected: Option<Vec<PrfSelected>>, pub selected: Option<Vec<PrfSelected>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(type = Option<std::collections::BTreeMap<std::string::String, std::string::String>>)]
pub latency_test_urls: Option<std::collections::BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<PrfExtra>, pub extra: Option<PrfExtra>,
@@ -229,6 +233,7 @@ impl PrfItem {
desc: Some(desc), desc: Some(desc),
file: Some(file), file: Some(file),
url: None, url: None,
latency_test_urls: None,
selected: None, selected: None,
extra: None, extra: None,
option: Some(PrfOption { option: Some(PrfOption {
@@ -408,6 +413,7 @@ impl PrfItem {
desc: desc.cloned(), desc: desc.cloned(),
file: Some(file), file: Some(file),
url: Some(url.as_str().into()), url: Some(url.as_str().into()),
latency_test_urls: None,
selected: None, selected: None,
extra, extra,
option: Some(PrfOption { option: Some(PrfOption {
+1
View File
@@ -250,6 +250,7 @@ impl IProfiles {
patch!(each, item, file); patch!(each, item, file);
patch!(each, item, url); patch!(each, item, url);
patch!(each, item, selected); patch!(each, item, selected);
patch!(each, item, latency_test_urls);
patch!(each, item, extra); patch!(each, item, extra);
patch!(each, item, updated); patch!(each, item, updated);
patch!(each, item, option); patch!(each, item, option);
+5
View File
@@ -73,6 +73,9 @@ pub struct IVerge {
pub collapse_navbar: Option<bool>, pub collapse_navbar: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_system_titlebar: Option<bool>,
pub sysproxy_tray_icon: Option<bool>, pub sysproxy_tray_icon: Option<bool>,
pub tun_tray_icon: Option<bool>, pub tun_tray_icon: Option<bool>,
@@ -374,6 +377,7 @@ impl IVerge {
menu_icon: Some("monochrome".into()), menu_icon: Some("monochrome".into()),
notice_position: Some("top-right".into()), notice_position: Some("top-right".into()),
collapse_navbar: Some(false), collapse_navbar: Some(false),
enable_system_titlebar: Some(true),
common_tray_icon: Some(false), common_tray_icon: Some(false),
sysproxy_tray_icon: Some(false), sysproxy_tray_icon: Some(false),
tun_tray_icon: Some(false), tun_tray_icon: Some(false),
@@ -461,6 +465,7 @@ impl IVerge {
patch!(proxy_group_tools_position); patch!(proxy_group_tools_position);
patch!(notice_position); patch!(notice_position);
patch!(collapse_navbar); patch!(collapse_navbar);
patch!(enable_system_titlebar);
patch!(common_tray_icon); patch!(common_tray_icon);
patch!(sysproxy_tray_icon); patch!(sysproxy_tray_icon);
patch!(tun_tray_icon); patch!(tun_tray_icon);
+97 -6
View File
@@ -1,8 +1,8 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::Serialize; use serde::{Serialize, Serializer, ser::SerializeMap as _};
use tauri_plugin_mihomo::models::{ use tauri_plugin_mihomo::models::{
DelayHistory, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType,
}; };
pub struct ProxyViewInput { pub struct ProxyViewInput {
@@ -57,6 +57,28 @@ pub struct ProxyCapabilities {
pub smux: bool, pub smux: bool,
} }
#[derive(Serialize, specta::Type)]
struct ProxyLatencyHistory<'a> {
#[specta(type = Vec<crate::ipc::DelayHistory>)]
history: &'a [DelayHistory],
}
fn serialize_proxy_latency_history<S: Serializer>(
extra: &HashMap<String, Extra>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(extra.len()))?;
for (url, extra) in extra {
map.serialize_entry(
url,
&ProxyLatencyHistory {
history: &extra.history,
},
)?;
}
map.end()
}
#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[derive(Debug, PartialEq, Eq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ProxyGroupView { pub struct ProxyGroupView {
@@ -76,6 +98,9 @@ pub struct ProxyGroupView {
pub test_url: Option<String>, pub test_url: Option<String>,
#[specta(type = Vec<crate::ipc::DelayHistory>)] #[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(flatten)] #[serde(flatten)]
pub capabilities: ProxyCapabilities, pub capabilities: ProxyCapabilities,
pub members: Vec<ProxyMemberRef>, pub members: Vec<ProxyMemberRef>,
@@ -91,6 +116,9 @@ pub struct ProxyNodeView {
pub proxy_type: ProxyType, pub proxy_type: ProxyType,
#[specta(type = Vec<crate::ipc::DelayHistory>)] #[specta(type = Vec<crate::ipc::DelayHistory>)]
pub history: Vec<DelayHistory>, pub history: Vec<DelayHistory>,
#[serde(serialize_with = "serialize_proxy_latency_history")]
#[specta(type = HashMap<String, ProxyLatencyHistory<'static>>)]
pub extra: HashMap<String, Extra>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -256,7 +284,7 @@ impl ProxyViewBuilder {
} else { } else {
ProxyViewProviderState::Unavailable ProxyViewProviderState::Unavailable
}; };
let (mut core_groups, core_nodes) = partition_core(proxies); let (mut core_groups, core_nodes) = partition_core(proxies, providers.is_some());
let (mut records, core_node_ids) = build_core_records(core_nodes); let (mut records, core_node_ids) = build_core_records(core_nodes);
let (providers, provider_candidates) = build_provider_records(providers, &mut records); let (providers, provider_candidates) = build_provider_records(providers, &mut records);
let resolver = MemberResolver { let resolver = MemberResolver {
@@ -289,14 +317,14 @@ impl ProxyViewBuilder {
} }
} }
fn partition_core(proxies: Proxies) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) { fn partition_core(proxies: Proxies, providers_available: bool) -> (BTreeMap<String, Proxy>, BTreeMap<String, Proxy>) {
let mut groups = BTreeMap::new(); let mut groups = BTreeMap::new();
let mut nodes = BTreeMap::new(); let mut nodes = BTreeMap::new();
for (name, proxy) in proxies.proxies { for (name, proxy) in proxies.proxies {
if proxy.all.is_some() { if proxy.all.is_some() {
groups.insert(name, proxy); groups.insert(name, proxy);
} else { } else if !providers_available || proxy.provider_name.is_empty() {
nodes.insert(name, proxy); nodes.insert(name, proxy);
} }
} }
@@ -403,6 +431,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
now, now,
test_url, test_url,
history, history,
extra,
udp, udp,
xudp, xudp,
tfo, tfo,
@@ -435,6 +464,7 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro
icon, icon,
test_url, test_url,
history, history,
extra,
capabilities: ProxyCapabilities { capabilities: ProxyCapabilities {
udp, udp,
xudp, xudp,
@@ -453,6 +483,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
icon, icon,
test_url, test_url,
history, history,
extra,
udp, udp,
xudp, xudp,
tfo, tfo,
@@ -467,6 +498,7 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo
name, name,
proxy_type, proxy_type,
history, history,
extra,
id, id,
hidden, hidden,
icon, icon,
@@ -520,3 +552,62 @@ fn build_standalone(core_node_ids: &BTreeMap<String, String>) -> Vec<String> {
); );
standalone standalone
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn provider_echo_in_proxies_does_not_override_group_membership() -> anyhow::Result<()> {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into(), "B".into()],
group_scopes: ["A", "B"]
.into_iter()
.map(|name| {
(
name.into(),
GroupScope {
providers: vec![name.into()],
exclude_types: vec![],
},
)
})
.collect(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"B": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "A", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}
}}))?,
providers: Some(serde_json::from_value(json!({"providers": {
"A": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 32}]}]},
"B": {"vehicleType": "Inline", "proxies": [{"name": "node", "history": [{"time": "2026-10-08T00:00:00Z", "delay": 123}]}]}
}}))?),
});
for (group, expected) in view.groups.iter().zip([32, 123]) {
let ProxyMemberRef::Node { record_id, .. } = &group.members[0] else {
anyhow::bail!("Expected node")
};
let record = &view.records[record_id];
assert_eq!(record.history.last().map(|entry| entry.delay), Some(expected));
assert!(
matches!(&record.source, ProxyNodeSource::Provider { provider_name, .. } if provider_name == &group.name)
);
}
Ok(())
}
#[test]
fn unavailable_provider_keeps_core_echo_interactable() -> anyhow::Result<()> {
let view = ProxyViewBuilder::build(ProxyViewInput {
runtime_group_order: vec!["A".into()],
group_scopes: BTreeMap::new(),
proxies: serde_json::from_value(json!({"proxies": {
"A": {"all": ["node"], "type": "Selector"},
"node": {"name": "node", "provider-name": "provider"}
}}))?,
providers: None,
});
assert!(matches!(&view.groups[0].members[0], ProxyMemberRef::Node { .. }));
Ok(())
}
}
+156
View File
@@ -0,0 +1,156 @@
use super::Tray;
use crate::{
Type, cmd,
config::Config,
core::{
handle,
notify::{Refresh, announce},
proxy_view::{ProxyMemberRef, ProxyNodeSource},
},
logging,
};
const TRAY_DELAY_TEST_CONCURRENCY: usize = 10;
const TRAY_DEFAULT_LATENCY_URL: &str = "http://cp.cloudflare.com/generate_204";
fn is_latency_test_url(raw: &str) -> bool {
let raw = raw.trim();
raw.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
&& tauri::Url::parse(raw).is_ok_and(|url| url.has_host() && matches!(url.scheme(), "http" | "https"))
}
pub(super) fn test_url(
group: &crate::core::proxy_view::ProxyGroupView,
verge: &crate::config::IVerge,
profiles: &crate::config::IProfiles,
) -> String {
let custom_url = profiles
.current
.as_ref()
.and_then(|uid| profiles.get_item(uid).ok())
.and_then(|profile| profile.latency_test_urls.as_ref())
.and_then(|urls| urls.get(group.name.as_str()))
.map(|url| url.as_str())
.filter(|url| !url.trim().is_empty());
let group_url = group.test_url.as_deref().filter(|url| !url.trim().is_empty());
let default_url = verge
.default_latency_test
.as_deref()
.filter(|url| !url.trim().is_empty());
custom_url
.or(group_url)
.or(default_url)
.filter(|&url| is_latency_test_url(url))
.unwrap_or(TRAY_DEFAULT_LATENCY_URL)
.trim()
.to_owned()
}
static RUNNING: parking_lot::Mutex<std::collections::BTreeSet<String>> =
parking_lot::Mutex::new(std::collections::BTreeSet::new());
pub(super) fn is_running(group_name: &str) -> bool {
RUNNING.lock().contains(group_name)
}
pub(super) async fn test_proxy_group_delay(group_name: &str) {
if !RUNNING.lock().insert(group_name.to_owned()) {
return;
}
Tray::global().refresh_latency_item(group_name);
let _running = scopeguard::guard(group_name, |name| {
RUNNING.lock().remove(name);
Tray::global().refresh_latency_item(name);
});
use futures::{StreamExt as _, stream};
let view = match cmd::proxy::get_proxy_view().await {
Ok(view) => view,
Err(err) => {
logging!(warn, Type::Tray, "Failed to read proxies for tray latency test: {err}");
return;
}
};
let verge = Config::verge().await.latest_arc();
let group = view
.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == group_name);
let Some(group) = group else {
logging!(warn, Type::Tray, "Tray latency test group not found: {group_name}");
return;
};
let profiles = Config::profiles().await.latest_arc();
let url = test_url(group, &verge, &profiles);
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000) as u32;
let mut targets: Vec<(std::string::String, Option<std::string::String>)> = Vec::with_capacity(group.members.len());
for member in &group.members {
match member {
ProxyMemberRef::Node { name, record_id } => match view.records.get(record_id).map(|node| &node.source) {
Some(ProxyNodeSource::Provider {
provider_name,
proxy_name,
}) => targets.push((proxy_name.clone(), Some(provider_name.clone()))),
_ => targets.push((name.clone(), None)),
},
ProxyMemberRef::Group { name } => targets.push((name.clone(), None)),
ProxyMemberRef::Unresolved { .. } => {}
}
}
let mihomo = handle::Handle::mihomo();
stream::iter(targets.into_iter().map(move |(member, provider)| {
let url = url.clone();
async move {
let result = match provider {
Some(provider_name) => {
mihomo
.healthcheck_node_in_provider(&provider_name, &member, &url, timeout)
.await
}
None => mihomo.delay_proxy_by_name(&member, &url, timeout).await,
};
if let Err(err) = result {
logging!(debug, Type::Tray, "Tray latency test failed: {member} - {err}");
}
}
}))
.buffer_unordered(TRAY_DELAY_TEST_CONCURRENCY)
.for_each(|()| std::future::ready(()))
.await;
if let Err(err) = cmd::proxy::sync_tray_proxy_selection().await {
logging!(warn, Type::Tray, "Failed to refresh tray after latency test: {err}");
}
announce(Refresh::Proxies);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_urls_require_a_valid_http_host_and_port() {
for invalid in [
"http://?",
"http://host:invalid",
"http://bad host/",
"ftp://localhost",
"http:localhost",
] {
assert!(!is_latency_test_url(invalid), "{invalid}");
}
for valid in ["http://localhost/", " https://example.com/ ", "HTTP://localhost:8080/"] {
assert!(is_latency_test_url(valid), "{valid}");
}
}
}
+1
View File
@@ -32,6 +32,7 @@ define_menu! {
outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes", outbound_modes => OUTBOUND_MODES, "tray_outbound_modes", "tray.outboundModes",
profiles => PROFILES, "tray_profiles", "tray.profiles", profiles => PROFILES, "tray_profiles", "tray.profiles",
proxies => PROXIES, "tray_proxies", "tray.proxies", proxies => PROXIES, "tray_proxies", "tray.proxies",
test_latency => TEST_LATENCY, "tray_test_latency", "tray.testLatency",
system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy", system_proxy => SYSTEM_PROXY, "tray_system_proxy", "tray.systemProxy",
tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode", tun_mode => TUN_MODE, "tray_tun_mode", "tray.tunMode",
close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections", close_all_connections => CLOSE_ALL_CONNECTIONS, "tray_close_all_connections", "tray.closeAllConnections",
+357 -136
View File
@@ -1,5 +1,6 @@
use crate::config::{IProfilePreview, IVerge}; use crate::config::{IProfilePreview, IVerge};
use crate::core::lightweight; use crate::core::lightweight;
use crate::core::proxy_view::{ProxyGroupView, ProxyMemberRef, ProxyNodeSource, ProxyViewProviderState, ProxyViewV1};
use crate::core::tray::menu_def::TrayAction; use crate::core::tray::menu_def::TrayAction;
use crate::process::AsyncHandler; use crate::process::AsyncHandler;
use crate::singleton; use crate::singleton;
@@ -14,7 +15,6 @@ use crate::{
use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter};
use clash_verge_logging::logging_error; use clash_verge_logging::logging_error;
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tauri_plugin_mihomo::models::Proxies;
use tokio::fs; use tokio::fs;
use super::handle; use super::handle;
@@ -28,6 +28,7 @@ use tauri::{
menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, menu::{CheckMenuItem, IsMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu},
}; };
mod latency;
mod menu_def; mod menu_def;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod speed_task; mod speed_task;
@@ -55,8 +56,25 @@ enum IconKind {
Tun, Tun,
} }
struct LatencyMenuItem {
item: MenuItem<Wry>,
running: bool,
proxies: Vec<ProxyLatencyItem>,
provider_state: ProxyViewProviderState,
}
struct ProxyLatencyItem {
item: CheckMenuItem<Wry>,
name: std::string::String,
provider_name: Option<std::string::String>,
text: std::string::String,
}
pub struct Tray { pub struct Tray {
limiter: SystemLimiter, limiter: SystemLimiter,
proxy_hover_limiter: SystemLimiter,
menu_update: tokio::sync::Mutex<()>,
latency_items: parking_lot::Mutex<HashMap<std::string::String, LatencyMenuItem>>,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController, speed_controller: speed_task::TraySpeedController,
} }
@@ -130,6 +148,9 @@ impl Default for Tray {
fn default() -> Self { fn default() -> Self {
Self { Self {
limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock), limiter: Limiter::new(Duration::from_millis(TRAY_CLICK_DEBOUNCE_MS), SystemClock),
proxy_hover_limiter: Limiter::new(Duration::from_secs(1), SystemClock),
menu_update: tokio::sync::Mutex::new(()),
latency_items: parking_lot::Mutex::new(HashMap::new()),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
speed_controller: speed_task::TraySpeedController::new(), speed_controller: speed_task::TraySpeedController::new(),
} }
@@ -195,6 +216,7 @@ impl Tray {
} }
async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> { async fn update_menu_internal(&self, app_handle: &AppHandle, include_proxy_groups: bool) -> Result<()> {
let _update = self.menu_update.lock().await;
let Some(tray) = app_handle.tray_by_id(TRAY_ID) else { let Some(tray) = app_handle.tray_by_id(TRAY_ID) else {
logging!(warn, Type::Tray, "Failed to update tray menu: tray not found"); logging!(warn, Type::Tray, "Failed to update tray menu: tray not found");
return Ok(()); return Ok(());
@@ -219,29 +241,133 @@ impl Tray {
let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default(); let profiles_preview = profiles_arc.profiles_preview().unwrap_or_default();
let is_lightweight_mode = is_in_lightweight_mode(); let is_lightweight_mode = is_in_lightweight_mode();
logging_error!( let (menu, latency_items) = create_tray_menu(
Type::Tray, app_handle,
tray.set_menu(Some( Some(mode.as_str()),
create_tray_menu( *system_proxy,
app_handle, *tun_mode,
Some(mode.as_str()), tun_mode_available,
*system_proxy, profiles_preview,
*tun_mode, TrayMenuOptions {
tun_mode_available, is_lightweight_mode,
profiles_preview, include_proxy_groups,
TrayMenuOptions { },
is_lightweight_mode, )
include_proxy_groups, .await?;
}, tray.set_menu(Some(menu))?;
) let groups: Vec<_> = latency_items.keys().cloned().collect();
.await?, *self.latency_items.lock() = latency_items;
)) for group in groups {
); self.refresh_latency_item(&group);
}
logging!(debug, Type::Tray, "托盘菜单更新成功"); logging!(debug, Type::Tray, "托盘菜单更新成功");
Ok(()) Ok(())
} }
async fn refresh_proxy_items(&'static self) {
let Ok(update) = self.menu_update.try_lock() else {
return;
};
if self.latency_items.lock().is_empty() || handle::Handle::global().is_exiting() {
return;
}
let Ok(Ok(view)) = tokio::time::timeout(
Duration::from_secs(1),
cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
)
.await
else {
return;
};
if self
.latency_items
.lock()
.values()
.any(|entry| entry.provider_state != view.provider_state)
{
// Provider fallback changes member identities; rebuild through the existing queue.
drop(update);
logging_error!(Type::Tray, cmd::proxy::sync_tray_proxy_selection().await);
return;
}
let verge = Config::verge().await.latest_arc();
let profiles = Config::profiles().await.latest_arc();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
// Keep rebuilding serialized until these handles have received their snapshot.
let _update = update;
let mut items = self.latency_items.lock();
for (group_name, entry) in items.iter_mut() {
let Some(group) = find_group(&view, group_name) else {
continue;
};
let url = latency::test_url(group, &verge, &profiles);
let mut members = HashMap::with_capacity(group.members.len());
for member in &group.members {
members
.entry((member_name(member), member_provider(&view, member)))
.or_insert(member);
}
for proxy in &mut entry.proxies {
let Some(member) = members.get(&(proxy.name.as_str(), proxy.provider_name.as_deref())) else {
continue;
};
let text = proxy_item_text(&view, member, &url, &verge);
let checked = group.now.as_ref() == Some(&proxy.name);
if text != proxy.text {
match proxy.item.set_text(&text) {
Ok(()) => proxy.text = text,
Err(err) => logging!(warn, Type::Tray, "Failed to refresh proxy latency: {err}"),
}
}
match proxy.item.is_checked() {
Ok(current) if current != checked => {
logging_error!(Type::Tray, proxy.item.set_checked(checked))
}
Err(err) => logging!(warn, Type::Tray, "Failed to read proxy selection: {err}"),
_ => {}
}
}
}
})
);
}
fn refresh_latency_item(&self, group_name: &str) {
let group_name = group_name.to_owned();
logging_error!(
Type::Tray,
handle::Handle::app_handle().run_on_main_thread(move || {
Self::global().refresh_latency_item_on_main_thread(&group_name);
})
);
}
fn refresh_latency_item_on_main_thread(&self, group_name: &str) {
let running = latency::is_running(group_name);
let item = {
let mut items = self.latency_items.lock();
items.get_mut(group_name).and_then(|entry| {
if entry.running == running {
return None;
}
entry.running = running;
Some(entry.item.clone())
})
};
if let Some(item) = item {
let label = if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
clash_verge_i18n::t!("tray.testLatency")
};
logging_error!(Type::Tray, item.set_text(label));
logging_error!(Type::Tray, item.set_enabled(!running));
}
}
pub async fn update_icon(&self, verge: &IVerge) -> Result<()> { pub async fn update_icon(&self, verge: &IVerge) -> Result<()> {
if handle::Handle::global().is_exiting() { if handle::Handle::global().is_exiting() {
logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新"); logging!(debug, Type::Tray, "应用正在退出,跳过托盘图标更新");
@@ -457,96 +583,175 @@ fn create_profile_menu_item(
.collect() .collect()
} }
const UNTESTED_DELAY_TEXT: &str = "-ms";
const TIMEOUT_DELAY_TEXT: &str = "T/O";
const TRAY_GROUP_DEPTH_LIMIT: usize = 8;
const fn member_name(member: &ProxyMemberRef) -> &str {
match member {
ProxyMemberRef::Node { name, .. }
| ProxyMemberRef::Group { name }
| ProxyMemberRef::Unresolved { name, .. } => name.as_str(),
}
}
fn find_group<'a>(view: &'a ProxyViewV1, name: &str) -> Option<&'a ProxyGroupView> {
view.groups
.iter()
.chain(view.global.iter())
.find(|group| group.name.as_str() == name)
}
/// The delay recorded for `url`, falling back to the node's latest measurement under any URL, so a
/// node the core tested on its own still shows a value.
fn node_delay(view: &ProxyViewV1, record_id: &str, url: &str) -> Option<u16> {
view.records
.get(record_id)
.map(|node| node.extra.get(url).map_or(&node.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
/// A group row shows the delay of the node the group currently selects. The core never writes a
/// history for the group itself, so `now` is followed until the chain reaches a node.
fn selected_member_delay(view: &ProxyViewV1, group_name: &str, url: &str) -> Option<u16> {
let mut current = group_name;
for _ in 0..TRAY_GROUP_DEPTH_LIMIT {
let Some(group) = find_group(view, current) else { break };
let Some(now) = group.now.as_deref().filter(|now| !now.is_empty() && *now != current) else {
break;
};
let Some(member) = group.members.iter().find(|member| member_name(member) == now) else {
break;
};
match member {
ProxyMemberRef::Node { record_id, .. } => return node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => current = name.as_str(),
ProxyMemberRef::Unresolved { .. } => break,
}
}
find_group(view, group_name)
.map(|group| group.extra.get(url).map_or(&group.history, |extra| &extra.history))
.and_then(|history| history.last())
.map(|history| history.delay)
}
fn member_provider<'a>(view: &'a ProxyViewV1, member: &ProxyMemberRef) -> Option<&'a str> {
let ProxyMemberRef::Node { record_id, .. } = member else {
return None;
};
match &view.records.get(record_id)?.source {
ProxyNodeSource::Provider { provider_name, .. } => Some(provider_name),
ProxyNodeSource::Core { .. } => None,
}
}
fn proxy_item_text(view: &ProxyViewV1, member: &ProxyMemberRef, url: &str, verge: &IVerge) -> std::string::String {
let timeout = verge
.default_latency_timeout
.filter(|timeout| *timeout > 0)
.unwrap_or(10000);
let delay = match member {
ProxyMemberRef::Node { record_id, .. } => node_delay(view, record_id, url),
ProxyMemberRef::Group { name } => selected_member_delay(view, name, url),
ProxyMemberRef::Unresolved { .. } => None,
};
let delay_text = delay.map_or_else(
|| UNTESTED_DELAY_TEXT.to_owned(),
|delay| {
if delay == 0 || u32::from(delay) >= timeout as u32 {
TIMEOUT_DELAY_TEXT.to_owned()
} else {
format!("{delay}ms")
}
},
);
format!("{} | {delay_text}", member_name(member))
}
fn create_subcreate_proxy_menu_item( fn create_subcreate_proxy_menu_item(
app_handle: &AppHandle, app_handle: &AppHandle,
proxy_mode: &str, proxy_mode: &str,
proxy_group_order_map: Option<HashMap<String, usize>>, view: Option<ProxyViewV1>,
proxy_nodes_data: Option<Proxies>, latency_items: &mut HashMap<std::string::String, LatencyMenuItem>,
texts: &MenuTexts,
verge: &IVerge,
profiles: &crate::config::IProfiles,
) -> Vec<Submenu<Wry>> { ) -> Vec<Submenu<Wry>> {
let proxy_submenus: Vec<Submenu<Wry>> = { let Some(view) = view else { return Vec::new() };
let mut submenus: Vec<(String, usize, Submenu<Wry>)> = Vec::new(); view.groups
.iter()
// TODO: 应用启动时,内核还未启动完全,无法获取代理节点信息 .chain(view.global.iter())
if let Some(proxy_nodes_data) = proxy_nodes_data { .filter_map(|group| {
for (group_name, group_data) in proxy_nodes_data.proxies.iter() { if group.hidden.unwrap_or_default() || (proxy_mode == "global") != (group.name == "GLOBAL") {
let should_show = match proxy_mode { return None;
"global" => group_name == "GLOBAL",
_ => group_name != "GLOBAL",
} && !group_data.hidden.unwrap_or_default();
if !should_show {
continue;
}
let Some(all_proxies) = group_data.all.as_ref() else {
continue;
};
let now_proxy = group_data.now.as_deref().unwrap_or_default();
let group_items: Vec<CheckMenuItem<Wry>> = all_proxies
.iter()
.filter_map(|proxy_str| {
let is_selected = *proxy_str == now_proxy;
let item_id = format!("proxy_{}_{}", group_name, proxy_str);
let delay_text = proxy_nodes_data
.proxies
.get(proxy_str)
.and_then(|h| h.history.last())
.map(|h| match h.delay {
0 => "-ms".into(),
delay if delay >= 10000 => "-ms".into(),
_ => format!("{}ms", h.delay),
})
.unwrap_or_else(|| "-ms".into());
let display_text = format!("{} | {}", proxy_str, delay_text);
CheckMenuItem::with_id(app_handle, item_id, display_text, true, is_selected, None::<&str>)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {}", e))
.ok()
})
.collect();
if group_items.is_empty() {
continue;
}
let group_display_name = group_name.to_string();
let group_items_refs: Vec<&dyn IsMenuItem<Wry>> =
group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>).collect();
if let Ok(submenu) = Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group_name),
group_display_name,
true,
&group_items_refs,
) {
let insertion_index = submenus.len();
submenus.push((group_name.into(), insertion_index, submenu));
} else {
logging!(warn, Type::Tray, "Failed to create proxy group submenu: {}", group_name);
}
} }
} let url = latency::test_url(group, verge, profiles);
let proxies: Vec<ProxyLatencyItem> = group
if let Some(order_map) = proxy_group_order_map.as_ref() { .members
submenus.sort_by(|(name_a, original_index_a, _), (name_b, original_index_b, _)| { .iter()
match (order_map.get(name_a), order_map.get(name_b)) { .filter_map(|member| {
(Some(index_a), Some(index_b)) => index_a.cmp(index_b), let name = member_name(member);
(Some(_), None) => std::cmp::Ordering::Less, let text = proxy_item_text(&view, member, &url, verge);
(None, Some(_)) => std::cmp::Ordering::Greater, let checked = group.now.as_deref() == Some(name);
(None, None) => original_index_a.cmp(original_index_b), CheckMenuItem::with_id(
} app_handle,
}); format!("proxy_{}_{}", group.name, name),
} &text,
true,
submenus.into_iter().map(|(_, _, submenu)| submenu).collect() checked,
}; None::<&str>,
proxy_submenus )
.map(|item| ProxyLatencyItem {
item,
name: name.to_owned(),
provider_name: member_provider(&view, member).map(str::to_owned),
text,
})
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy menu item: {e}"))
.ok()
})
.collect();
let group_items: Vec<_> = proxies.iter().map(|proxy| proxy.item.clone()).collect();
if group_items.is_empty() {
return None;
}
let running = latency::is_running(&group.name);
let test_item = MenuItem::with_id(
app_handle,
format!("{}_{}", MenuIds::TEST_LATENCY, group.name),
if running {
clash_verge_i18n::t!("tray.testingLatency")
} else {
texts.test_latency.clone()
},
!running,
None::<&str>,
)
.ok()?;
latency_items.insert(
group.name.clone(),
LatencyMenuItem {
item: test_item.clone(),
running,
proxies,
provider_state: view.provider_state,
},
);
let separator = PredefinedMenuItem::separator(app_handle).ok()?;
let mut items: Vec<&dyn IsMenuItem<Wry>> = vec![&test_item, &separator];
items.extend(group_items.iter().map(|item| item as &dyn IsMenuItem<Wry>));
Submenu::with_id_and_items(
app_handle,
format!("proxy_group_{}", group.name),
&group.name,
true,
&items,
)
.map_err(|e| logging!(warn, Type::Tray, "Failed to create proxy group submenu: {e}"))
.ok()
})
.collect()
} }
fn create_proxy_menu_item( fn create_proxy_menu_item(
@@ -593,54 +798,38 @@ async fn create_tray_menu(
tun_mode_available: bool, tun_mode_available: bool,
profiles_preview: Vec<IProfilePreview<'_>>, profiles_preview: Vec<IProfilePreview<'_>>,
options: TrayMenuOptions, options: TrayMenuOptions,
) -> Result<tauri::menu::Menu<Wry>> { ) -> Result<(tauri::menu::Menu<Wry>, HashMap<std::string::String, LatencyMenuItem>)> {
let current_proxy_mode = mode.unwrap_or(""); let current_proxy_mode = mode.unwrap_or("");
let mut verge_settings = Config::verge().await.latest_arc(); let mut verge_settings = Config::verge().await.latest_arc();
let fetch_proxy_groups = let fetch_proxy_groups =
options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable"); options.include_proxy_groups && verge_settings.tray_proxy_groups_display_mode.as_deref() != Some("disable");
// TODO: should update tray menu again when it was timeout error // Proxy data must remain optional so a stopped Core cannot roll back unrelated settings.
let (proxy_nodes_data, runtime_proxy_groups_order) = if fetch_proxy_groups { let proxy_view = if fetch_proxy_groups {
let proxy_nodes_data = tokio::time::timeout(
tokio::time::timeout(Duration::from_millis(1000), handle::Handle::mihomo().get_proxies()) Duration::from_millis(1000),
.await cmd::proxy::proxy_view(Some(Duration::from_millis(750))),
.map_or(None, |res| res.ok()); )
.await
let runtime = Config::runtime().await.latest_arc(); .ok()
let runtime_proxy_groups_order = runtime.config.as_ref().map(|config| { .and_then(Result::ok)
config
.get("proxy-groups")
.and_then(|groups| groups.as_sequence())
.map(|groups| {
groups
.iter()
.filter_map(|group| group.get("name"))
.filter_map(|name| name.as_str())
.enumerate()
.map(|(index, name)| (name.into(), index))
.collect::<HashMap<String, usize>>()
})
.unwrap_or_default()
});
(proxy_nodes_data, runtime_proxy_groups_order)
} else { } else {
(None, None) None
}; };
if fetch_proxy_groups { if fetch_proxy_groups {
verge_settings = Config::verge().await.latest_arc(); verge_settings = Config::verge().await.latest_arc();
} }
let profiles_config = Config::profiles().await.latest_arc();
let tray_proxy_groups_display_mode = verge_settings let tray_proxy_groups_display_mode = verge_settings
.tray_proxy_groups_display_mode .tray_proxy_groups_display_mode
.as_deref() .as_deref()
.unwrap_or("default"); .unwrap_or("default");
let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable"; let include_proxy_groups = options.include_proxy_groups && tray_proxy_groups_display_mode != "disable";
let proxy_group_order_map = runtime_proxy_groups_order;
let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false); let show_outbound_modes_inline = verge_settings.tray_inline_outbound_modes.unwrap_or(false);
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
@@ -720,9 +909,17 @@ async fn create_tray_menu(
&profile_menu_items_refs, &profile_menu_items_refs,
)?; )?;
let mut latency_items = HashMap::new();
let (proxies_menu, inline_proxy_items) = if include_proxy_groups { let (proxies_menu, inline_proxy_items) = if include_proxy_groups {
let proxy_sub_menus = let proxy_sub_menus = create_subcreate_proxy_menu_item(
create_subcreate_proxy_menu_item(app_handle, current_proxy_mode, proxy_group_order_map, proxy_nodes_data); app_handle,
current_proxy_mode,
proxy_view,
&mut latency_items,
&texts,
&verge_settings,
&profiles_config,
);
match tray_proxy_groups_display_mode { match tray_proxy_groups_display_mode {
"default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?, "default" => create_proxy_menu_item(app_handle, false, proxy_sub_menus, &texts.proxies)?,
@@ -866,10 +1063,25 @@ async fn create_tray_menu(
]); ]);
let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?; let menu = tauri::menu::MenuBuilder::new(app_handle).items(&menu_items).build()?;
Ok(menu) Ok((menu, latency_items))
} }
fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) { fn on_tray_icon_event(_tray_icon: &TrayIcon, tray_event: TrayIconEvent) {
let refresh_proxies = match tray_event {
TrayIconEvent::Enter { .. } => Tray::global().proxy_hover_limiter.check(),
TrayIconEvent::Click {
button: MouseButton::Left | MouseButton::Right,
button_state: MouseButtonState::Down,
..
} => true,
_ => false,
};
if refresh_proxies {
AsyncHandler::spawn(|| async {
Tray::global().refresh_proxy_items().await;
});
}
if matches!( if matches!(
tray_event, tray_event,
TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. } TrayIconEvent::Move { .. } | TrayIconEvent::Leave { .. } | TrayIconEvent::Enter { .. }
@@ -991,6 +1203,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
}; };
feat::toggle_proxy_profile(profile_index.into()).await; feat::toggle_proxy_profile(profile_index.into()).await;
} }
id if id.starts_with(MenuIds::TEST_LATENCY) => {
let group_name = id
.strip_prefix(MenuIds::TEST_LATENCY)
.and_then(|rest| rest.strip_prefix('_'));
if let Some(group_name) = group_name {
logging!(info, Type::Tray, "Tray menu: test group latency {}", group_name);
latency::test_proxy_group_delay(group_name).await;
}
}
id if id.starts_with("proxy_") => { id if id.starts_with("proxy_") => {
let rest = match id.strip_prefix("proxy_") { let rest = match id.strip_prefix("proxy_") {
Some(r) => r, Some(r) => r,
+1
View File
@@ -67,6 +67,7 @@ verge_registry! {
proxy_group_tools_position => NoEffect; proxy_group_tools_position => NoEffect;
notice_position => NoEffect; notice_position => NoEffect;
collapse_navbar => NoEffect; collapse_navbar => NoEffect;
enable_system_titlebar => NoEffect;
sysproxy_tray_icon => [TrayIcon]; sysproxy_tray_icon => [TrayIcon];
tun_tray_icon => [TrayIcon]; tun_tray_icon => [TrayIcon];
enable_tun_mode => Tun; enable_tun_mode => Tun;
+3 -1
View File
@@ -130,7 +130,7 @@ pub async fn open_or_close_dashboard() {
logging!(info, Type::Window, "Window toggle result: {result:?}"); logging!(info, Type::Window, "Window toggle result: {result:?}");
} }
pub async fn quit() { pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
logging!(debug, Type::System, "启动退出流程"); logging!(debug, Type::System, "启动退出流程");
// 设置退出标志 // 设置退出标志
handle::Handle::global().set_is_exiting(); handle::Handle::global().set_is_exiting();
@@ -165,11 +165,13 @@ pub async fn quit() {
NoticeStatus::AppQuitCoreStopFailed, NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(), cleanup_result.stop_error.unwrap_or_default(),
); );
return clash_verge_signal::ShutdownOutcome::Canceled;
} }
utils::server::shutdown_embedded_server(); utils::server::shutdown_embedded_server();
let app_handle = handle::Handle::app_handle(); let app_handle = handle::Handle::app_handle();
app_handle.exit(if cleanup_result.all_success { 0 } else { 1 }); app_handle.exit(if cleanup_result.all_success { 0 } else { 1 });
clash_verge_signal::ShutdownOutcome::Committed
} }
pub async fn clean_async() -> CleanupResult { pub async fn clean_async() -> CleanupResult {
+5 -2
View File
@@ -116,9 +116,13 @@ mod app_init {
/// Setup window state management /// Setup window state management
pub fn setup_window_state(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> { pub fn setup_window_state(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
logging!(debug, Type::Setup, "初始化窗口状态管理..."); logging!(debug, Type::Setup, "初始化窗口状态管理...");
// Decorations are config-driven (`enable_system_titlebar`); restoring a stale
// value here would override the builder's `.decorations(...)` on window recreate.
let state_flags =
tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::DECORATIONS;
let window_state_plugin = tauri_plugin_window_state::Builder::new() let window_state_plugin = tauri_plugin_window_state::Builder::new()
.with_filename(files::WINDOW_STATE) .with_filename(files::WINDOW_STATE)
.with_state_flags(tauri_plugin_window_state::StateFlags::default()) .with_state_flags(state_flags)
.build(); .build();
app.handle().plugin(window_state_plugin)?; app.handle().plugin(window_state_plugin)?;
Ok(()) Ok(())
@@ -230,7 +234,6 @@ pub fn run() -> std::process::ExitCode {
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve::resolve_setup_async(); resolve::resolve_setup_async();
resolve::resolve_setup_sync(); resolve::resolve_setup_sync();
#[cfg(target_os = "linux")]
resolve::init_signal(); resolve::init_signal();
})) { })) {
log_setup_panic("window-core", panic); log_setup_panic("window-core", panic);
+1 -1
View File
@@ -16,7 +16,7 @@ fn main() -> ExitCode {
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))] #[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
fn main() -> ExitCode { fn main() -> ExitCode {
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let worker_limit = std::cmp::min(default_parallelism, 4); let worker_limit = std::cmp::min(default_parallelism, 8);
let blocking_limit = 2 * worker_limit; let blocking_limit = 2 * worker_limit;
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
+1 -2
View File
@@ -20,6 +20,7 @@ use crate::{
utils::{init, server, window_manager::WindowManager}, utils::{init, server, window_manager::WindowManager},
}; };
use clash_verge_logging::{Type, logging, logging_error}; use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod dns; pub mod dns;
@@ -188,8 +189,6 @@ async fn init_silent_updater() {
logging!(info, Type::Setup, "Silent updater initialized"); logging!(info, Type::Setup, "Silent updater initialized");
} }
// Upstream has no session-end signal support on Linux; see crates/clash-verge-signal.
#[cfg(target_os = "linux")]
pub(crate) fn init_signal() { pub(crate) fn init_signal() {
logging!(debug, Type::Setup, "Initializing signal handlers..."); logging!(debug, Type::Setup, "Initializing signal handlers...");
clash_verge_signal::register(feat::quit); clash_verge_signal::register(feat::quit);
+2 -6
View File
@@ -19,11 +19,6 @@ const DEFAULT_HEIGHT: f64 = 700.0;
const MINIMAL_WIDTH: f64 = 520.0; const MINIMAL_WIDTH: f64 = 520.0;
const MINIMAL_HEIGHT: f64 = 520.0; const MINIMAL_HEIGHT: f64 = 520.0;
#[cfg(target_os = "linux")]
const DEFAULT_DECORATIONS: bool = false;
#[cfg(not(target_os = "linux"))]
const DEFAULT_DECORATIONS: bool = true;
const fn restored_window_size_is_too_small(width: u32, height: u32) -> bool { const fn restored_window_size_is_too_small(width: u32, height: u32) -> bool {
width < MINIMAL_WIDTH as u32 || height < MINIMAL_HEIGHT as u32 width < MINIMAL_WIDTH as u32 || height < MINIMAL_HEIGHT as u32
} }
@@ -49,6 +44,7 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
let config = Config::verge().await; let config = Config::verge().await;
let latest = config.latest_arc(); let latest = config.latest_arc();
let decorations = latest.enable_system_titlebar.unwrap_or(true);
let start_page = latest.start_page.as_deref().unwrap_or("/"); let start_page = latest.start_page.as_deref().unwrap_or("/");
let initial_theme_mode = match latest.theme_mode.as_deref() { let initial_theme_mode = match latest.theme_mode.as_deref() {
Some("dark") => "dark", Some("dark") => "dark",
@@ -83,7 +79,7 @@ pub async fn build_new_window() -> Result<WebviewWindow, String> {
) )
.title("Clash Verge") .title("Clash Verge")
.center() .center()
.decorations(DEFAULT_DECORATIONS) .decorations(decorations)
.fullscreen(false) .fullscreen(false)
.inner_size(DEFAULT_WIDTH, DEFAULT_HEIGHT) .inner_size(DEFAULT_WIDTH, DEFAULT_HEIGHT)
.min_inner_size(MINIMAL_WIDTH, MINIMAL_HEIGHT) .min_inner_size(MINIMAL_WIDTH, MINIMAL_HEIGHT)
+52 -1
View File
@@ -39,6 +39,8 @@ static INSTANCE_LOCK: OnceLock<std::fs::File> = OnceLock::new();
const PAC_INITIAL_AVAILABLE: bool = false; const PAC_INITIAL_AVAILABLE: bool = false;
static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE); static PAC_AVAILABLE: AtomicBool = AtomicBool::new(PAC_INITIAL_AVAILABLE);
static COMMANDS_READY: AtomicBool = AtomicBool::new(false); static COMMANDS_READY: AtomicBool = AtomicBool::new(false);
#[cfg(feature = "verge-dev")]
static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingletonDisposition { pub enum SingletonDisposition {
@@ -162,12 +164,55 @@ fn instance_auth(token: String) -> impl warp::Filter<Extract = (), Error = warp:
.untuple_one() .untuple_one()
} }
#[cfg(feature = "verge-dev")]
fn dev_quit_route<F>(
token: String,
commands_ready: &'static AtomicBool,
quit_requested: &'static AtomicBool,
trigger: F,
) -> impl warp::Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone
where
F: Fn() + Clone + Send + Sync + 'static,
{
instance_auth(token)
.and(warp::path!("commands" / "dev" / "quit"))
.and(warp::post())
.and_then(move || {
let trigger = trigger.clone();
async move {
if !commands_ready.load(Ordering::Acquire) {
return Ok::<_, warp::Rejection>(warp::reply::with_status(
String::from("starting"),
warp::http::StatusCode::SERVICE_UNAVAILABLE,
));
}
if quit_requested
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
trigger();
}
Ok(warp::reply::with_status(
String::from("accepted"),
warp::http::StatusCode::ACCEPTED,
))
}
})
}
#[cfg(feature = "verge-dev")]
fn release_dev_quit_latch(quit_requested: &AtomicBool, outcome: clash_verge_signal::ShutdownOutcome) {
if matches!(outcome, clash_verge_signal::ShutdownOutcome::Canceled) {
quit_requested.store(false, Ordering::Release);
}
}
fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
let (shutdown_tx, shutdown_rx) = oneshot::channel(); let (shutdown_tx, shutdown_rx) = oneshot::channel();
let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx))); let _ = SHUTDOWN_SENDER.set(Mutex::new(Some(shutdown_tx)));
#[cfg(feature = "verge-dev")] #[cfg(feature = "verge-dev")]
let auth = instance_auth(token); let auth = instance_auth(token.clone());
#[cfg(not(feature = "verge-dev"))] #[cfg(not(feature = "verge-dev"))]
let auth = instance_auth(token); let auth = instance_auth(token);
@@ -233,6 +278,12 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
}); });
let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac); let commands = auth.clone().and(visible).or(auth.and(scheme)).or(pac);
#[cfg(feature = "verge-dev")]
let commands = commands.or(dev_quit_route(token, &COMMANDS_READY, &DEV_QUIT_REQUESTED, || {
AsyncHandler::spawn(|| async {
release_dev_quit_latch(&DEV_QUIT_REQUESTED, crate::feat::quit().await);
});
}));
AsyncHandler::spawn(move || async move { AsyncHandler::spawn(move || async move {
warp::serve(commands) warp::serve(commands)
.incoming(listener) .incoming(listener)
+11 -33
View File
@@ -27,12 +27,8 @@ const TRAY_STATUS_ITEM_EXTRA_WIDTH: f64 = 30.0;
const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0; const TRAY_STATUS_ITEM_MIN_LENGTH: f64 = 58.0;
const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0; const NS_VARIABLE_STATUS_ITEM_LENGTH: f64 = -1.0;
/// (button_height, mono_advance, attributes).
type CachedAttrs = (f64, f64, Retained<NSDictionary<NSString, AnyObject>>);
thread_local! { thread_local! {
static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) }; static LAST_DISPLAY_STR: RefCell<String> = const { RefCell::new(String::new()) };
static CACHED_ATTRS: RefCell<Option<CachedAttrs>> = const { RefCell::new(None) };
} }
fn format_tray_speed(up: u64, down: u64) -> String { fn format_tray_speed(up: u64, down: u64) -> String {
@@ -78,26 +74,8 @@ fn create_attributed_string(
} }
} }
/// Attributes and per-glyph advance, rebuilt only when the button height changes. fn status_item_length_for_speed(attr_str: &NSAttributedString) -> f64 {
fn cached_attributes(button_height: f64) -> (f64, Retained<NSDictionary<NSString, AnyObject>>) { (attr_str.size().width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH)
CACHED_ATTRS.with(|cache| {
if let Some((height, advance, attrs)) = cache.borrow().as_ref()
&& *height == button_height
{
return (*advance, attrs.clone());
}
let attrs = build_attributes(button_height);
let probe = NSString::from_str("0");
let advance = create_attributed_string(&probe, Some(&attrs)).size().width;
*cache.borrow_mut() = Some((button_height, advance, attrs.clone()));
(advance, attrs)
})
}
/// Longest line times the monospaced advance; matches `size().width` for the ASCII-only speed text.
fn arithmetic_width(text: &str, advance: f64) -> f64 {
let glyphs = text.split('\n').map(str::len).max().unwrap_or(0) as f64;
glyphs * advance
} }
fn sync_click_target_frame(button: &NSStatusBarButton) { fn sync_click_target_frame(button: &NSStatusBarButton) {
@@ -111,7 +89,7 @@ fn sync_click_target_frame(button: &NSStatusBarButton) {
} }
/// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not. /// `with_inner_tray_icon` should run this on the main thread; fail safely if it does not.
fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, show_speed: bool) { fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &NSString, show_speed: bool) {
let Some(mtm) = MainThreadMarker::new() else { let Some(mtm) = MainThreadMarker::new() else {
logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用"); logging!(warn, Type::Tray, "托盘速率富文本设置跳过:非主线程调用");
return; return;
@@ -119,19 +97,17 @@ fn apply_status_item_attributed_title(status_item: &NSStatusItem, text: &str, sh
let Some(button) = status_item.button(mtm) else { let Some(button) = status_item.button(mtm) else {
return; return;
}; };
let ns_string = NSString::from_str(text);
let attr_str = if show_speed { let attr_str = if show_speed {
let (advance, attrs) = cached_attributes(button.bounds().size.height); let attrs = build_attributes(button.bounds().size.height);
let attrs: &NSDictionary<NSString, AnyObject> = &attrs; let attrs: &NSDictionary<NSString, AnyObject> = &attrs;
let attr_str = create_attributed_string(&ns_string, Some(attrs)); let attr_str = create_attributed_string(text, Some(attrs));
let width = arithmetic_width(text, advance); let length = status_item_length_for_speed(&attr_str);
let length = (width.ceil() + TRAY_STATUS_ITEM_EXTRA_WIDTH).max(TRAY_STATUS_ITEM_MIN_LENGTH);
if status_item.length() != length { if status_item.length() != length {
status_item.setLength(length); status_item.setLength(length);
} }
attr_str attr_str
} else { } else {
create_attributed_string(&ns_string, None) create_attributed_string(text, None)
}; };
button.setAttributedTitle(&attr_str); button.setAttributedTitle(&attr_str);
sync_click_target_frame(&button); sync_click_target_frame(&button);
@@ -145,7 +121,8 @@ pub fn set_speed_attributed_title(status_item: &NSStatusItem, up: u64, down: u64
return; return;
} }
*last = speed_text; *last = speed_text;
apply_status_item_attributed_title(status_item, last.as_str(), true); let ns_string = NSString::from_str(last.as_str());
apply_status_item_attributed_title(status_item, &ns_string, true);
}); });
} }
@@ -153,6 +130,7 @@ pub fn clear_speed_attributed_title(status_item: &NSStatusItem) {
LAST_DISPLAY_STR.with(|last| { LAST_DISPLAY_STR.with(|last| {
last.borrow_mut().clear(); last.borrow_mut().clear();
}); });
let empty = NSString::from_str("");
status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH); status_item.setLength(NS_VARIABLE_STATUS_ITEM_LENGTH);
apply_status_item_attributed_title(status_item, "", false); apply_status_item_attributed_title(status_item, &empty, false);
} }
-13
View File
@@ -202,15 +202,9 @@
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 10px;
box-sizing: border-box; box-sizing: border-box;
height: 36px; height: 36px;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
&-drag-region {
align-self: stretch;
flex: 1 1 auto;
}
} }
.layout-content__left .the-logo { .layout-content__left .the-logo {
@@ -235,16 +229,9 @@
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
padding: 10px;
box-sizing: border-box; box-sizing: border-box;
height: 36px; height: 36px;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
&-drag-region {
align-self: stretch;
flex: 1 1 auto;
order: 1;
}
} }
.layout-content__left { .layout-content__left {
@@ -47,7 +47,10 @@ import {
useCoreDataStatus, useCoreDataStatus,
useProxiesData, useProxiesData,
} from '@/providers/app-data-context' } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { profileTestUrls } from '@/services/proxy-test-url'
import { import {
findCurrentGroupMember, findCurrentGroupMember,
getRecord, getRecord,
@@ -675,6 +678,20 @@ export const CurrentProxyCard = () => {
[proxyView], [proxyView],
) )
useEffect(() => {
if (!selectedGroup) return
const customUrl = currentProfile
? profileTestUrls(currentProfile)[selectedGroup.name]
: undefined
delayManager.setUrl(
selectedGroup.name,
customUrl?.trim() ||
selectedGroup.testUrl?.trim() ||
verge?.default_latency_test?.trim() ||
'http://cp.cloudflare.com/generate_204',
)
}, [selectedGroup, currentProfile, verge?.default_latency_test])
const unsortedProxyOptions = useMemo( const unsortedProxyOptions = useMemo(
() => optionsForGroup(selectedGroup), () => optionsForGroup(selectedGroup),
[optionsForGroup, selectedGroup], [optionsForGroup, selectedGroup],
@@ -907,6 +924,12 @@ export const CurrentProxyCard = () => {
} }
refreshProxy() refreshProxy()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[CurrentProxyCard] Failed to sync tray latency:', error)
})
}) })
const proxyOptions = useMemo( const proxyOptions = useMemo(
+69 -38
View File
@@ -1,13 +1,13 @@
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material' import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material' import { Box, IconButton } from '@mui/material'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { import {
forwardRef, type MouseEvent,
type PointerEvent, type PointerEvent,
useCallback, useCallback,
useImperativeHandle, useEffect,
} from 'react' } from 'react'
import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
const RESIZE_HANDLES = [ const RESIZE_HANDLES = [
@@ -21,9 +21,7 @@ const RESIZE_HANDLES = [
{ direction: 'NorthWest', position: 'north-west' }, { direction: 'NorthWest', position: 'north-west' },
] as const ] as const
export const WindowResizeHandles = () => { export const WindowResizeHandles = ({ maximized }: { maximized: boolean }) => {
const { currentWindow, maximized } = useWindowControls()
const startResizeDragging = useCallback( const startResizeDragging = useCallback(
(event: PointerEvent<HTMLDivElement>) => { (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return if (event.button !== 0) return
@@ -33,14 +31,14 @@ export const WindowResizeHandles = () => {
const handle = RESIZE_HANDLES.find((item) => item.direction === direction) const handle = RESIZE_HANDLES.find((item) => item.direction === direction)
if (handle) { if (handle) {
void currentWindow void getCurrentWindow()
.startResizeDragging(handle.direction) .startResizeDragging(handle.direction)
.catch((error) => .catch((error) =>
console.warn('[WindowResizeHandles] 调整窗口大小失败:', error), console.warn('[WindowResizeHandles] 调整窗口大小失败:', error),
) )
} }
}, },
[currentWindow], [],
) )
if (getSystem() !== 'linux' || maximized) return null if (getSystem() !== 'linux' || maximized) return null
@@ -63,44 +61,77 @@ export const WindowResizeHandles = () => {
) )
} }
export const WindowControls = forwardRef(function WindowControls(props, ref) { export const WindowControls = ({
maximized,
setMaximized,
}: {
maximized: boolean
setMaximized: (value: boolean) => void
}) => {
const OS = getSystem() const OS = getSystem()
const {
currentWindow,
maximized,
minimize,
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls()
useImperativeHandle( const close = useCallback(async () => {
ref, // Delay one frame so the UI can clear :hover before the window hides.
() => ({ await new Promise((resolve) => setTimeout(resolve, 20))
currentWindow, await getCurrentWindow().close()
maximized, }, [])
minimize,
close, const minimize = useCallback(async () => {
toggleFullscreen, // Delay one frame so the UI can clear :hover before the window hides.
toggleMaximize, await new Promise((resolve) => setTimeout(resolve, 10))
}), await getCurrentWindow().minimize()
[ }, [])
currentWindow,
maximized, const toggleMaximize = useCallback(async () => {
minimize, const currentWindow = getCurrentWindow()
close, if (await currentWindow.isMaximized()) {
toggleFullscreen, await currentWindow.unmaximize()
toggleMaximize, setMaximized(false)
], } else {
await currentWindow.maximize()
setMaximized(true)
}
}, [setMaximized])
useEffect(() => {
getCurrentWindow().setMinimizable(true)
}, [])
const handleTitlebarMouseDown = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
// Left button only, and only on the first press: detail 2 is the second
// press of a double-click, which `handleTitlebarDoubleClick` turns into maximize.
if (event.button !== 0 || event.detail !== 1) return
// Only the empty bar area drags; the control buttons keep their own clicks.
if (event.target !== event.currentTarget) return
event.preventDefault()
void getCurrentWindow()
.startDragging()
.catch((error) => console.warn('[WindowControls] 开始拖动失败:', error))
},
[],
) )
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 const handleTitlebarDoubleClick = useCallback(
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 (event: MouseEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return
void toggleMaximize().catch((error) =>
console.warn('[WindowControls] 切换最大化失败:', error),
)
},
[toggleMaximize],
)
return ( return (
<Box <Box
onMouseDown={handleTitlebarMouseDown}
onDoubleClick={handleTitlebarDoubleClick}
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: OS === 'macos' ? 'flex-start' : 'flex-end',
width: '100%',
height: '100%',
px: 1,
gap: 1, gap: 1,
alignItems: 'center', alignItems: 'center',
'> button': { '> button': {
@@ -186,4 +217,4 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
)} )}
</Box> </Box>
) )
}) }
-7
View File
@@ -75,7 +75,6 @@ interface ProxyChainProps {
onMarkUnsavedChanges?: () => void onMarkUnsavedChanges?: () => void
mode?: string mode?: string
selectedGroup?: string | null selectedGroup?: string | null
onExitChainMode: () => void
} }
interface ProxyChainItemProps { interface ProxyChainItemProps {
@@ -332,7 +331,6 @@ export const ProxyChain = ({
onMarkUnsavedChanges, onMarkUnsavedChanges,
mode, mode,
selectedGroup, selectedGroup,
onExitChainMode,
}: ProxyChainProps) => { }: ProxyChainProps) => {
const theme = useTheme() const theme = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
@@ -698,18 +696,13 @@ export const ProxyChain = ({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 2,
height: '100%', height: '100%',
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}} }}
> >
<Typography>{t('proxies.page.chain.empty')}</Typography> <Typography>{t('proxies.page.chain.empty')}</Typography>
<Button size="small" variant="outlined" onClick={onExitChainMode}>
{t('shared.actions.close')}
</Button>
</Box> </Box>
) : ( ) : (
<DragDropProvider <DragDropProvider
+11 -6
View File
@@ -9,7 +9,7 @@ import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded'
import VisibilityRounded from '@mui/icons-material/VisibilityRounded' import VisibilityRounded from '@mui/icons-material/VisibilityRounded'
import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded' import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded'
import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded' import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded'
import { Box, IconButton, type SxProps, TextField } from '@mui/material' import { Box, IconButton, type SxProps } from '@mui/material'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react' import { memo, useEffect, useRef } from 'react'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
@@ -18,10 +18,11 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network' import { isValidLatencyTestUrl } from '@/utils/network'
import { BaseSearchBox, type SearchState } from '../base' import { BaseSearchBox, type SearchState } from '../base'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort' import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state' import type { HeadState } from './use-head-state'
@@ -69,7 +70,10 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url]) }, [groupName, testUrl, defaultLatencyUrl, url])
// 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染,
@@ -112,7 +116,8 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
)} )}
{textState === 'url' && ( {textState === 'url' && (
<TextField <ProxyTestUrlInput
name={groupName}
inputRef={inputRef} inputRef={inputRef}
autoComplete="new-password" autoComplete="new-password"
hiddenLabel hiddenLabel
@@ -125,7 +130,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
}} }}
onChange={(e) => onHeadState({ testUrl: e.target.value })} onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/> />
)} )}
@@ -176,7 +181,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (testUrl?.trim() && textState !== 'filter') { if (testUrl?.trim() && textState !== 'filter') {
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) { if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl') showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return return
} }
@@ -77,7 +77,6 @@ interface GroupSelectMenuProps {
interface ProxyGroupsChainProps { interface ProxyGroupsChainProps {
mode: string mode: string
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
availableGroups: any[] availableGroups: any[]
activeSelectedGroup: string | null activeSelectedGroup: string | null
showScrollTop: boolean showScrollTop: boolean
@@ -306,7 +305,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
const { const {
mode, mode,
chainConfigData, chainConfigData,
onExitChainMode,
availableGroups, availableGroups,
activeSelectedGroup, activeSelectedGroup,
showScrollTop, showScrollTop,
@@ -523,7 +521,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
chainConfigData={chainConfigData} chainConfigData={chainConfigData}
mode={mode} mode={mode}
selectedGroup={activeSelectedGroup} selectedGroup={activeSelectedGroup}
onExitChainMode={onExitChainMode}
/> />
</Box> </Box>
</Box> </Box>
+16 -24
View File
@@ -23,7 +23,9 @@ import { useProfiles } from '@/hooks/use-profiles'
import { useProxySelection } from '@/hooks/use-proxy-selection' import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useProxiesData, useSystemData } from '@/providers/app-data-context' import { useProxiesData, useSystemData } from '@/providers/app-data-context'
import { syncTrayProxySelection } from '@/services/cmds'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { mutate } from '@/services/mutate'
import { import {
isInteractableMember, isInteractableMember,
resolveMember, resolveMember,
@@ -42,7 +44,6 @@ import {
ProxyGroupNavigator, ProxyGroupNavigator,
} from './proxy-group-navigator' } from './proxy-group-navigator'
import { ProxyRender } from './proxy-render' import { ProxyRender } from './proxy-render'
import type { HeadState } from './use-head-state'
import { import {
PROXY_GROUP_HEADER_SENSORS, PROXY_GROUP_HEADER_SENSORS,
useProxyGroupHeaderLayout, useProxyGroupHeaderLayout,
@@ -69,7 +70,6 @@ interface Props {
mode: string mode: string
isChainMode?: boolean isChainMode?: boolean
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
} }
function useEmptyRenderList() { function useEmptyRenderList() {
@@ -90,11 +90,11 @@ function useProxyRenderState(
) { ) {
const { verge } = useVerge() const { verge } = useVerge()
const { proxyView } = useProxiesData() const { proxyView } = useProxiesData()
const { const { renderList, onProxies, onHeadState } = useRenderList(
renderList, mode,
onProxies, isChainMode,
onHeadState: setListHeadState, activeSelectedGroup,
} = useRenderList(mode, isChainMode, activeSelectedGroup) )
const scrollPositionKey = useMemo( const scrollPositionKey = useMemo(
() => () =>
isChainMode isChainMode
@@ -103,14 +103,6 @@ function useProxyRenderState(
[activeSelectedGroup, isChainMode, mode], [activeSelectedGroup, isChainMode, mode],
) )
const onHeadState = useCallback(
(groupName: string, patch: Partial<HeadState>) => {
if (patch.open) onProxies()
setListHeadState(groupName, patch)
},
[onProxies, setListHeadState],
)
const timeout = verge?.default_latency_timeout || 10000 const timeout = verge?.default_latency_timeout || 10000
const handleCheckAll = useStableCallback( const handleCheckAll = useStableCallback(
@@ -143,6 +135,12 @@ function useProxyRenderState(
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally { } finally {
onProxies() onProxies()
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxyGroups] Failed to sync tray latency:', error)
})
} }
}), }),
) )
@@ -192,9 +190,8 @@ function useProxyRenderState(
function ChainProxyGroups(props: { function ChainProxyGroups(props: {
mode: string mode: string
chainConfigData?: string | null chainConfigData?: string | null
onExitChainMode: () => void
}) { }) {
const { mode, chainConfigData, onExitChainMode } = props const { mode, chainConfigData } = props
const { proxyView } = useProxiesData() const { proxyView } = useProxiesData()
const [selectedGroup, setSelectedGroup] = useState<string | null>(null) const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
@@ -349,7 +346,6 @@ function ChainProxyGroups(props: {
<ProxyGroupsChain <ProxyGroupsChain
mode={mode} mode={mode}
chainConfigData={chainConfigData} chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
availableGroups={availableGroups} availableGroups={availableGroups}
activeSelectedGroup={activeSelectedGroup} activeSelectedGroup={activeSelectedGroup}
showScrollTop={showScrollTop} showScrollTop={showScrollTop}
@@ -621,7 +617,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, chainConfigData } = props
const { profiles, isLoading: isProfilesLoading } = useProfiles() const { profiles, isLoading: isProfilesLoading } = useProfiles()
const { isProxyViewPending } = useProxiesData() const { isProxyViewPending } = useProxiesData()
const { isRunningModePending } = useSystemData() const { isRunningModePending } = useSystemData()
@@ -643,11 +639,7 @@ export const ProxyGroups = (props: Props) => {
return <ProxyEmptyState reason={listState.reason} /> return <ProxyEmptyState reason={listState.reason} />
case 'render': case 'render':
return isChainMode ? ( return isChainMode ? (
<ChainProxyGroups <ChainProxyGroups mode={mode} chainConfigData={chainConfigData} />
mode={mode}
chainConfigData={chainConfigData}
onExitChainMode={onExitChainMode}
/>
) : ( ) : (
<NormalProxyGroups mode={mode} /> <NormalProxyGroups mode={mode} />
) )
+11 -6
View File
@@ -11,7 +11,7 @@ import {
SortByAlphaRounded, SortByAlphaRounded,
SortRounded, SortRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, IconButton, TextField, type SxProps } from '@mui/material' import { Box, IconButton, type SxProps } from '@mui/material'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -20,8 +20,9 @@ import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay' import delayManager from '@/services/delay'
import { showNotice } from '@/services/notice-service' import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network' import { isValidLatencyTestUrl } from '@/utils/network'
import { ProxyTestUrlInput } from './proxy-test-url-input'
import type { ProxySortType } from './use-filter-sort' import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state' import type { HeadState } from './use-head-state'
@@ -72,7 +73,10 @@ export const ProxyHead = ({
'http://cp.cloudflare.com/generate_204' 'http://cp.cloudflare.com/generate_204'
useEffect(() => { useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) delayManager.setUrl(
groupName,
testUrl?.trim() || url?.trim() || defaultLatencyUrl,
)
}, [groupName, testUrl, defaultLatencyUrl, url]) }, [groupName, testUrl, defaultLatencyUrl, url])
return ( return (
@@ -97,7 +101,7 @@ export const ProxyHead = ({
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`) debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' }) onHeadState({ textState: 'url' })
} }
if (testUrl?.trim() && !isValidUrl(testUrl)) { if (testUrl?.trim() && !isValidLatencyTestUrl(testUrl)) {
showNotice.warning('proxies.feedback.warnings.invalidTestUrl') showNotice.warning('proxies.feedback.warnings.invalidTestUrl')
return return
} }
@@ -188,7 +192,8 @@ export const ProxyHead = ({
)} )}
{textState === 'url' && ( {textState === 'url' && (
<TextField <ProxyTestUrlInput
name={groupName}
autoComplete="new-password" autoComplete="new-password"
autoFocus={autoFocus} autoFocus={autoFocus}
hiddenLabel hiddenLabel
@@ -197,7 +202,7 @@ export const ProxyHead = ({
size="small" size="small"
variant="outlined" variant="outlined"
placeholder={t('proxies.page.placeholders.delayCheckUrl')} placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onChange={(e) => onHeadState({ testUrl: e.target.value })} onCommit={(testUrl) => onHeadState({ testUrl })}
sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/> />
)} )}
@@ -0,0 +1,55 @@
import { TextField, type TextFieldProps } from '@mui/material'
import { useEffect, useEffectEvent, useRef, useState } from 'react'
import { useProfiles } from '@/hooks/use-profiles'
type Props = Omit<TextFieldProps, 'value' | 'onChange'> & {
value: string
onCommit: (url: string) => void
}
export const ProxyTestUrlInput = ({ value, name, ...props }: Props) => {
const { profiles } = useProfiles()
return (
<DraftTestUrlInput
key={JSON.stringify([profiles?.current, name])}
name={name}
value={value}
{...props}
/>
)
}
const DraftTestUrlInput = ({ value, onCommit, ...props }: Props) => {
const [draft, setDraft] = useState<string | null>(null)
const pendingRef = useRef<string | null>(null)
const commitOnUnmount = useEffectEvent(() => {
if (pendingRef.current !== null && pendingRef.current !== value)
onCommit(pendingRef.current)
})
useEffect(() => () => commitOnUnmount(), [])
return (
<TextField
{...props}
value={draft ?? value}
onChange={(event) => {
pendingRef.current = event.target.value
setDraft(event.target.value)
}}
onBlur={() => {
const next = pendingRef.current
pendingRef.current = null
setDraft(null)
if (next !== null && next !== value) onCommit(next)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
const input = event.target as HTMLInputElement
input.blur()
}
}}
/>
)
}
+12 -4
View File
@@ -13,11 +13,17 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) =>
kind: 'node', kind: 'node',
ref: { kind: 'node', name, recordId: `${memberIndex}` }, ref: { kind: 'node', name, recordId: `${memberIndex}` },
node: { node: {
recordId: `${memberIndex}`,
history: [{ delay }], history: [{ delay }],
extra: {
'http://cp.cloudflare.com/generate_204': {
history: [{ time: new Date(0).toISOString(), delay }],
},
},
source: { kind: 'provider', providerName: `${memberIndex}` }, source: { kind: 'provider', providerName: `${memberIndex}` },
}, },
}, },
}) as ResolvedMemberOccurrence }) as unknown as ResolvedMemberOccurrence
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
@@ -36,7 +42,9 @@ test('matches the previous comparator for cached, fallback and sentinel delays',
for (const cached of [false, true]) { for (const cached of [false, true]) {
const group = `sort-${cached}` const group = `sort-${cached}`
if (cached) { if (cached) {
values.forEach((delay, i) => delayManager.setDelay(`${i}`, group, delay)) values.forEach((delay, i) =>
delayManager.setDelay(list[i].member, group, delay),
)
} }
for (const timeout of [10000, 20, 0, NaN]) { for (const timeout of [10000, 20, 0, NaN]) {
const expected = list const expected = list
@@ -66,7 +74,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
node(3, 25), node(3, 25),
] ]
const cachedOrder = [list[0], list[2], list[1], list[3]] const cachedOrder = [list[0], list[2], list[1], list[3]]
delayManager.setDelay('0', 'expiry', 1) delayManager.setDelay(list[0].member, 'expiry', 1)
const get = vi.spyOn(delayManager, 'getDelayFix') const get = vi.spyOn(delayManager, 'getDelayFix')
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
expect(get).toHaveBeenCalledTimes(list.length) expect(get).toHaveBeenCalledTimes(list.length)
@@ -80,7 +88,7 @@ test('reads each occurrence once and observes cache updates and expiry on the ne
list[3], list[3],
list[0], list[0],
]) ])
delayManager.setDelay('0', 'expiry', 2) delayManager.setDelay(list[0].member, 'expiry', 2)
expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder) expect(filterSort(list, 'expiry', '', 1)).toEqual(cachedOrder)
get.mockClear() get.mockClear()
filterSort([], 'expiry', '', 1) filterSort([], 'expiry', '', 1)
+60 -18
View File
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useReducer } from 'react' import { useCallback, useEffect, useReducer } from 'react'
import { useProfiles } from '@/hooks/use-profiles' import { useProfiles } from '@/hooks/use-profiles'
import {
profileTestUrls,
commitProfileTestUrl,
} from '@/services/proxy-test-url'
import { ProxySortType } from './use-filter-sort' import { ProxySortType } from './use-filter-sort'
@@ -32,22 +36,45 @@ export const DEFAULT_STATE: HeadState = {
} }
type HeadStateAction = type HeadStateAction =
| { type: 'reset' } | { type: 'reset'; profile: string }
| { type: 'replace'; payload: Record<string, HeadState> } | { type: 'replace'; profile: string; payload: Record<string, HeadState> }
| { type: 'update'; groupName: string; patch: Partial<HeadState> } | { type: 'update'; groupName: string; patch: Partial<HeadState> }
| { type: 'urls'; urls: Record<string, string> }
interface LoadedHeadState {
profile: string | null
heads: Record<string, HeadState>
}
function headStateReducer( function headStateReducer(
state: Record<string, HeadState>, state: LoadedHeadState,
action: HeadStateAction, action: HeadStateAction,
): Record<string, HeadState> { ): LoadedHeadState {
switch (action.type) { switch (action.type) {
case 'reset': case 'reset':
return {} return { profile: action.profile, heads: {} }
case 'replace': case 'replace':
return action.payload return { profile: action.profile, heads: action.payload }
case 'urls': {
let heads = state.heads
for (const [name, testUrl] of Object.entries(action.urls)) {
if (heads[name]?.testUrl === testUrl) continue
heads = {
...heads,
[name]: { ...DEFAULT_STATE, ...heads[name], testUrl },
}
}
return heads === state.heads ? state : { ...state, heads }
}
case 'update': { case 'update': {
const prev = state[action.groupName] || DEFAULT_STATE const prev = state.heads[action.groupName] || DEFAULT_STATE
return { ...state, [action.groupName]: { ...prev, ...action.patch } } return {
...state,
heads: {
...state.heads,
[action.groupName]: { ...prev, ...action.patch },
},
}
} }
default: default:
return state return state
@@ -55,30 +82,42 @@ function headStateReducer(
} }
export function useHeadStateNew() { export function useHeadStateNew() {
const { profiles } = useProfiles() const { profiles, current: profile } = useProfiles()
const current = profiles?.current || '' const current = profiles?.current || ''
const [state, dispatch] = useReducer(headStateReducer, {}) const [{ profile: loadedProfile, heads: state }, dispatch] = useReducer(
headStateReducer,
{ profile: null, heads: {} },
)
useEffect(() => { useEffect(() => {
const urls = profile ? profileTestUrls(profile) : {}
if (loadedProfile === current) {
dispatch({ type: 'urls', urls })
return
}
try { try {
const data = JSON.parse( const data = JSON.parse(
localStorage.getItem(HEAD_STATE_KEY)!, localStorage.getItem(HEAD_STATE_KEY) || '{}',
) as HeadStateStorage ) as HeadStateStorage
const value = data[current] || {} const value = data?.[current] || {}
for (const [name, testUrl] of Object.entries(urls)) {
value[name] = { ...DEFAULT_STATE, ...value[name], testUrl }
}
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
dispatch({ type: 'replace', payload: value }) dispatch({ type: 'replace', profile: current, payload: value })
} else { } else {
dispatch({ type: 'reset' }) dispatch({ type: 'reset', profile: current })
} }
} catch { } catch {
dispatch({ type: 'reset' }) dispatch({ type: 'reset', profile: current })
} }
}, [current]) }, [current, loadedProfile, profile])
useEffect(() => { useEffect(() => {
if (loadedProfile !== current) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
try { try {
const item = localStorage.getItem(HEAD_STATE_KEY) const item = localStorage.getItem(HEAD_STATE_KEY)
@@ -94,13 +133,16 @@ export function useHeadStateNew() {
}) })
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [state, current]) }, [state, current, loadedProfile])
const setHeadState = useCallback( const setHeadState = useCallback(
(groupName: string, obj: Partial<HeadState>) => { (groupName: string, obj: Partial<HeadState>) => {
dispatch({ type: 'update', groupName, patch: obj }) dispatch({ type: 'update', groupName, patch: obj })
if (current && obj.testUrl !== undefined) {
void commitProfileTestUrl(current, groupName, obj.testUrl)
}
}, },
[], [current],
) )
return [state, setHeadState] as const return [state, setHeadState] as const
+1
View File
@@ -116,6 +116,7 @@ const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({
mptcp: false, mptcp: false,
smux: false, smux: false,
history: [], history: [],
extra: {},
members, members,
}) })
@@ -1,11 +1,20 @@
import { Button, Grid, Stack, TextField } from '@mui/material' import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import {
TextField,
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import { memo, useEffect, useRef } from 'react' import { useState, useRef, memo, useEffect } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { createWebdavBackup, saveWebdavConfig } from '@/services/cmds' import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service' import { errorDetail, showNotice } from '@/services/notice-service'
import { import {
@@ -34,6 +43,7 @@ export const BackupConfigViewer = memo(
const { t } = useTranslation() const { t } = useTranslation()
const { verge, mutateVerge } = useVerge() const { verge, mutateVerge } = useVerge()
const { webdav_url, webdav_username, webdav_password } = verge || {} const { webdav_url, webdav_username, webdav_password } = verge || {}
const [showPassword, setShowPassword] = useState(false)
const usernameRef = useRef<HTMLInputElement>(null) const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null) const passwordRef = useRef<HTMLInputElement>(null)
const urlRef = useRef<HTMLInputElement>(null) const urlRef = useRef<HTMLInputElement>(null)
@@ -58,6 +68,10 @@ export const BackupConfigViewer = memo(
const webdavStatus = getWebdavStatus(webdavSignature) const webdavStatus = getWebdavStatus(webdavSignature)
const shouldAutoInit = webdavStatus !== 'failed' const shouldAutoInit = webdavStatus !== 'failed'
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev)
}
useEffect(() => { useEffect(() => {
if (!webdav_url || !webdav_username || !webdav_password) { if (!webdav_url || !webdav_username || !webdav_password) {
return return
@@ -201,7 +215,7 @@ export const BackupConfigViewer = memo(
<Grid size={{ xs: 6 }}> <Grid size={{ xs: 6 }}>
<TextField <TextField
label={t('shared.labels.password')} label={t('shared.labels.password')}
type="password" type={showPassword ? 'text' : 'password'}
variant="outlined" variant="outlined"
size="small" size="small"
autoCorrect="off" autoCorrect="off"
@@ -209,6 +223,20 @@ export const BackupConfigViewer = memo(
spellCheck="false" spellCheck="false"
inputRef={passwordRef} inputRef={passwordRef}
{...register('password')} {...register('password')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/> />
</Grid> </Grid>
</Grid> </Grid>
+47 -11
View File
@@ -12,16 +12,21 @@ import {
} from '@mui/material' } from '@mui/material'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { join } from '@tauri-apps/api/path' import { join } from '@tauri-apps/api/path'
import { getCurrentWindow } from '@tauri-apps/api/window'
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, useImperativeHandle, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import {
BaseDialog,
type DialogRef,
Switch,
TooltipIcon,
} from '@/components/base'
import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator' import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator'
import { useVerge } from '@/hooks/use-verge' import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window' import { copyIconFile, getAppDir, restartApp } from '@/services/cmds'
import { copyIconFile, getAppDir } 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 getSystem from '@/utils/get-system' import getSystem from '@/utils/get-system'
@@ -58,8 +63,6 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
const [sysproxyIcon, setSysproxyIcon] = useState('') const [sysproxyIcon, setSysproxyIcon] = useState('')
const [tunIcon, setTunIcon] = useState('') const [tunIcon, setTunIcon] = useState('')
const { decorated, toggleDecorations } = useWindowDecorations()
useEffect(() => { useEffect(() => {
initIconPath() initIconPath()
}, []) }, [])
@@ -123,17 +126,50 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
<List> <List>
<Item> <Item>
<ListItemText <ListItemText
primary={t( primary={
'settings.components.verge.layout.fields.preferSystemTitlebar', <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
)} <span>
{t(
'settings.components.verge.layout.fields.enableSystemTitlebar',
)}
</span>
<TooltipIcon
title={t(
'settings.components.verge.layout.tooltips.enableSystemTitlebar',
)}
sx={{ opacity: '0.7' }}
/>
</Box>
}
/> />
<GuardState <GuardState
value={decorated} value={verge?.enable_system_titlebar ?? true}
valueProps="checked" valueProps="checked"
onCatch={onError} onCatch={onError}
onFormat={onSwitchFormat} onFormat={onSwitchFormat}
onChange={async () => { onChange={(e) => onChangeData({ enable_system_titlebar: e })}
await toggleDecorations() onGuard={async (e) => {
await patchVerge({ enable_system_titlebar: e })
if (OS === 'linux') {
void mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
}).catch((error) => {
console.warn('[LayoutViewer] failed to restart app:', error)
})
} else {
const currentWindow = getCurrentWindow()
try {
await currentWindow.setDecorations(e)
// macOS clears the Miniaturizable style bit when decorations are disabled; re-enable minimize.
await currentWindow.setMinimizable(true)
} catch (error) {
console.warn(
'[LayoutViewer] failed to apply system titlebar setting:',
error,
)
}
}
}} }}
> >
<Switch edge="end" /> <Switch edge="end" />
+11 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import type { ProfilePatch, ProfileSelection } from '@/services/bindings' import type { ProfilePatch, ProfileSelection } from '@/services/bindings'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate' import { mutate } from '@/services/mutate'
import { migrateProfileTestUrls } from '@/services/proxy-test-url'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug' import { debugLog } from '@/utils/debug'
@@ -35,6 +36,10 @@ export const useProfiles = () => {
refetchInterval: false, refetchInterval: false,
}) })
useEffect(() => {
if (profiles) migrateProfileTestUrls(profiles)
}, [profiles])
const refetchRef = useRef(refetch) const refetchRef = useRef(refetch)
refetchRef.current = refetch refetchRef.current = refetch
const mutateProfiles = useCallback(async () => { const mutateProfiles = useCallback(async () => {
@@ -46,7 +51,6 @@ export const useProfiles = () => {
try { try {
const result = await mutate(() => patchProfilesConfig(value), { const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config', id: 'patch-profiles-config',
errorNotice: false,
onFulfilled: (outcome) => { onFulfilled: (outcome) => {
if (outcome.status === 'valid') { if (outcome.status === 'valid') {
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) => void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
@@ -56,8 +60,12 @@ export const useProfiles = () => {
}, },
}) })
if (!result.ok) {
// Backend Busy keeps local state untouched, as before.
return result.value
}
const outcome = result.value const outcome = result.value
if (outcome.status !== 'valid') { if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles() await mutateProfiles()
} }
return outcome return outcome
+12 -9
View File
@@ -34,7 +34,6 @@ export function useProxyDelayState(
groupName: string, groupName: string,
): UseProxyDelayState { ): UseProxyDelayState {
const name = member.ref.name const name = member.ref.name
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved' const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name) const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY) const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
@@ -43,18 +42,18 @@ export function useProxyDelayState(
useEffect(() => { useEffect(() => {
if (isPreset) return if (isPreset) return
delayManager.setListener(name, groupName, setDelayState) delayManager.setListener(member, groupName, setDelayState)
return () => { return () => {
delayManager.removeListener(name, groupName) delayManager.removeListener(member, groupName)
} }
}, [name, groupName, isPreset]) }, [member, groupName, isPreset])
const updateDelay = useCallback(() => { const updateDelay = useCallback(() => {
if (unresolved) { if (unresolved) {
setDelayState(INITIAL_DELAY) setDelayState(INITIAL_DELAY)
return return
} }
const cachedUpdate = delayManager.getDelayUpdate(name, groupName) const cachedUpdate = delayManager.getDelayUpdate(member, groupName)
if (cachedUpdate) { if (cachedUpdate) {
setDelayState({ ...cachedUpdate }) setDelayState({ ...cachedUpdate })
return return
@@ -67,7 +66,9 @@ export function useProxyDelayState(
} }
let updatedAt = 0 let updatedAt = 0
const history = details?.history const history =
delayManager.getHistory(member, groupName) ??
memberDetails(member)?.history
if (history && history.length > 0) { if (history && history.length > 0) {
const lastRecord = history[history.length - 1] const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time) const parsed = Date.parse(lastRecord.time)
@@ -77,16 +78,18 @@ export function useProxyDelayState(
} }
setDelayState({ delay: fallbackDelay, updatedAt }) setDelayState({ delay: fallbackDelay, updatedAt })
}, [details?.history, groupName, member, name, unresolved]) }, [groupName, member, unresolved])
useEffect(() => { useEffect(() => {
updateDelay() updateDelay()
}, [updateDelay]) return delayManager.addGroupListener(groupName, updateDelay)
}, [groupName, updateDelay])
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return if (!isInteractableMember(member)) return
setDelayState({ delay: -2, updatedAt: Date.now() }) setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(await delayManager.checkDelay(member, groupName, timeout)) await delayManager.checkDelay(member, groupName, timeout)
updateDelay()
}) })
return { return {
+41
View File
@@ -0,0 +1,41 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect, useState } from 'react'
import debounce from '@/utils/debounce'
export const useWindowMaximized = (enabled: boolean) => {
const [maximized, setMaximized] = useState(false)
useEffect(() => {
if (!enabled) return
const currentWindow = getCurrentWindow()
let isUnmounted = false
const syncMaximized = async () => {
if (isUnmounted) return
try {
setMaximized(await currentWindow.isMaximized())
} catch (error) {
console.warn('[useWindowMaximized] 读取最大化状态失败:', error)
}
}
void syncMaximized()
const unlistenPromise = currentWindow.onResized(
debounce(syncMaximized, 300),
)
return () => {
isUnmounted = true
unlistenPromise
.then((unlisten) => unlisten())
.catch((err) =>
console.warn('[useWindowMaximized] 清理监听器失败:', err),
)
}
}, [enabled])
return { maximized, setMaximized }
}
-46
View File
@@ -1,46 +0,0 @@
import { use } from 'react'
import { WindowContext, type WindowContextType } from '@/providers/window'
const useWindow = () => {
const context = use(WindowContext)
if (context === undefined) {
throw new Error('useWindow must be used within WindowProvider')
}
return context
}
export const useWindowControls = () => {
const {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} = useWindow()
return {
maximized,
minimize,
toggleMaximize,
close,
toggleFullscreen,
currentWindow,
} satisfies Pick<
WindowContextType,
| 'maximized'
| 'minimize'
| 'toggleMaximize'
| 'close'
| 'toggleFullscreen'
| 'currentWindow'
>
}
export const useWindowDecorations = () => {
const { decorated, toggleDecorations, refreshDecorated } = useWindow()
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
WindowContextType,
'decorated' | 'toggleDecorations' | 'refreshDecorated'
>
}
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "إعدادات التخطيط", "title": "إعدادات التخطيط",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "تفعيل شريط عنوان النظام",
"trafficGraph": "مخطط حركة المرور", "trafficGraph": "مخطط حركة المرور",
"memoryUsage": "استهلاك الذاكرة", "memoryUsage": "استهلاك الذاكرة",
"proxyGroupIcon": "أيقونة مجموعة الوكلاء", "proxyGroupIcon": "أيقونة مجموعة الوكلاء",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "إيقاف عرض إحصاءات حركة المرور مؤقتًا عند فقدان التركيز" "pauseRenderTrafficStatsOnBlur": "إيقاف عرض إحصاءات حركة المرور مؤقتًا عند فقدان التركيز"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "استخدام شريط عنوان النظام الأصلي بدلاً من شريط العنوان المخصص المدمج (يلزم إعادة التشغيل على Linux).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Proxy-Kette konfigurieren", "header": "Proxy-Kette konfigurieren",
"warning": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.", "warning": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.",
"confirmEnable": "Verstanden, aktivieren",
"keepDisabled": "Deaktiviert lassen",
"empty": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.", "empty": "Eine Proxy-Kette verringert die Netzwerkgeschwindigkeit deutlich. Wenn Sie keinen klaren Bedarf haben oder diese Konfiguration nicht verstehen, lassen Sie sie deaktiviert.",
"instruction": "Klicken Sie die Knoten nacheinander an, um sie zur Proxy-Kette hinzuzufügen", "instruction": "Klicken Sie die Knoten nacheinander an, um sie zur Proxy-Kette hinzuzufügen",
"minimumNodes": "Eine Proxy-Kette braucht mindestens 2 Knoten", "minimumNodes": "Eine Proxy-Kette braucht mindestens 2 Knoten",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Layout-Einstellungen", "title": "Layout-Einstellungen",
"fields": { "fields": {
"preferSystemTitlebar": "System-Titelleiste bevorzugen", "enableSystemTitlebar": "System-Titelleiste aktivieren",
"trafficGraph": "Verkehrsdiagramm", "trafficGraph": "Verkehrsdiagramm",
"memoryUsage": "Kern-Speichernutzung", "memoryUsage": "Kern-Speichernutzung",
"proxyGroupIcon": "Proxy-Gruppen-Symbol", "proxyGroupIcon": "Proxy-Gruppen-Symbol",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Rendering der Verkehrsstatistiken bei Fokusverlust pausieren" "pauseRenderTrafficStatsOnBlur": "Rendering der Verkehrsstatistiken bei Fokusverlust pausieren"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Die native Titelleiste des Systems anstelle der integrierten benutzerdefinierten Titelleiste verwenden (unter Linux ist ein Neustart erforderlich).",
"hoverNavigator": "Beim Darüberfahren mit der Maus über die Buchstaben automatisch zur passenden Proxy-Gruppe scrollen", "hoverNavigator": "Beim Darüberfahren mit der Maus über die Buchstaben automatisch zur passenden Proxy-Gruppe scrollen",
"hoverNavigatorDelay": "Wartezeit vor dem automatischen Scrollen beim Darüberfahren, in Millisekunden" "hoverNavigatorDelay": "Wartezeit vor dem automatischen Scrollen beim Darüberfahren, in Millisekunden"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Layout Setting", "title": "Layout Setting",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "Enable System Titlebar",
"trafficGraph": "Traffic Graph", "trafficGraph": "Traffic Graph",
"memoryUsage": "Core Usage", "memoryUsage": "Core Usage",
"proxyGroupIcon": "Proxy Group Icon", "proxyGroupIcon": "Proxy Group Icon",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Pause rendering traffic statistics when blurred" "pauseRenderTrafficStatsOnBlur": "Pause rendering traffic statistics when blurred"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Use the system's native title bar instead of the built-in custom title bar (restart required on Linux).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Configuración de la interfaz", "title": "Configuración de la interfaz",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "Activar barra de título del sistema",
"trafficGraph": "Gráfico de tráfico", "trafficGraph": "Gráfico de tráfico",
"memoryUsage": "Uso de memoria del núcleo", "memoryUsage": "Uso de memoria del núcleo",
"proxyGroupIcon": "Icono del grupo de proxy", "proxyGroupIcon": "Icono del grupo de proxy",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Pausar el renderizado de estadísticas de tráfico al perder el foco" "pauseRenderTrafficStatsOnBlur": "Pausar el renderizado de estadísticas de tráfico al perder el foco"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Usar la barra de título nativa del sistema en lugar de la barra de título personalizada integrada (se requiere reiniciar en Linux).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "تنظیمات چیدمان", "title": "تنظیمات چیدمان",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "فعالسازی نوار عنوان سیستم",
"trafficGraph": "نمودار ترافیک", "trafficGraph": "نمودار ترافیک",
"memoryUsage": "استفاده از حافظه", "memoryUsage": "استفاده از حافظه",
"proxyGroupIcon": "آیکون گروه پراکسی", "proxyGroupIcon": "آیکون گروه پراکسی",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "توقف رندر آمار ترافیک هنگام از دست رفتن فوکوس" "pauseRenderTrafficStatsOnBlur": "توقف رندر آمار ترافیک هنگام از دست رفتن فوکوس"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "استفاده از نوار عنوان بومی سیستم بهجای نوار عنوان سفارشی داخلی (در لینوکس نیاز به راهاندازی مجدد است).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Pengaturan Tata Letak", "title": "Pengaturan Tata Letak",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "Aktifkan bilah judul sistem",
"trafficGraph": "Grafik Lalu Lintas", "trafficGraph": "Grafik Lalu Lintas",
"memoryUsage": "Penggunaan Memori", "memoryUsage": "Penggunaan Memori",
"proxyGroupIcon": "Ikon Grup Proksi", "proxyGroupIcon": "Ikon Grup Proksi",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Jeda render statistik lalu lintas saat jendela tidak fokus" "pauseRenderTrafficStatsOnBlur": "Jeda render statistik lalu lintas saat jendela tidak fokus"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Gunakan bilah judul asli sistem sebagai ganti bilah judul kustom bawaan (perlu mulai ulang di Linux).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "レイアウト設定", "title": "レイアウト設定",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "システムタイトルバーを有効にする",
"trafficGraph": "トラフィックグラフ", "trafficGraph": "トラフィックグラフ",
"memoryUsage": "コアメモリ使用量", "memoryUsage": "コアメモリ使用量",
"proxyGroupIcon": "プロキシグループアイコン", "proxyGroupIcon": "プロキシグループアイコン",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "フォーカスを失ったときにトラフィック統計の描画を一時停止" "pauseRenderTrafficStatsOnBlur": "フォーカスを失ったときにトラフィック統計の描画を一時停止"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "内蔵のカスタムタイトルバーの代わりにシステム標準のタイトルバーを使用します(Linux では再起動が必要です)。",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "체인 프록시 구성", "header": "체인 프록시 구성",
"warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.", "warning": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"confirmEnable": "이해했습니다, 활성화",
"keepDisabled": "비활성화 유지",
"empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.", "empty": "체인 프록시는 네트워크 속도를 크게 저하시킬 수 있습니다. 명확한 업무상 필요가 없거나 이 구성을 이해하지 못한다면 비활성화 상태로 두세요.",
"instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요", "instruction": "프록시 체인에 추가하려면 순서대로 노드를 클릭하세요",
"minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다", "minimumNodes": "체인 프록시는 최소 2개의 노드가 필요합니다",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "레이아웃 설정", "title": "레이아웃 설정",
"fields": { "fields": {
"preferSystemTitlebar": "시스템 제목 표시줄 사용", "enableSystemTitlebar": "시스템 제목 표시줄 활성화",
"trafficGraph": "트래픽 그래프", "trafficGraph": "트래픽 그래프",
"memoryUsage": "메모리 사용량", "memoryUsage": "메모리 사용량",
"proxyGroupIcon": "프록시 그룹 아이콘", "proxyGroupIcon": "프록시 그룹 아이콘",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "포커스를 잃으면 트래픽 통계 렌더링 일시 중지" "pauseRenderTrafficStatsOnBlur": "포커스를 잃으면 트래픽 통계 렌더링 일시 중지"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "내장 사용자 지정 제목 표시줄 대신 시스템 기본 제목 표시줄을 사용합니다(Linux에서는 다시 시작해야 함).",
"hoverNavigator": "알파벳에 마우스를 올리면 해당 프록시 그룹으로 자동 스크롤합니다", "hoverNavigator": "알파벳에 마우스를 올리면 해당 프록시 그룹으로 자동 스크롤합니다",
"hoverNavigatorDelay": "호버 시 자동 스크롤까지의 지연(밀리초)" "hoverNavigatorDelay": "호버 시 자동 스크롤까지의 지연(밀리초)"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Configuração do proxy em cadeia", "header": "Configuração do proxy em cadeia",
"warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", "warning": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.",
"confirmEnable": "Entendi, ativar",
"keepDisabled": "Manter desativado",
"empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.", "empty": "O proxy em cadeia reduz significativamente a velocidade da rede. Se você não tem uma necessidade clara ou não entende esta configuração, mantenha-o desativado.",
"instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy", "instruction": "Clique nos nós em ordem para adicioná-los à cadeia de proxy",
"minimumNodes": "O proxy em cadeia requer pelo menos 2 nós", "minimumNodes": "O proxy em cadeia requer pelo menos 2 nós",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Configurações de Layout", "title": "Configurações de Layout",
"fields": { "fields": {
"preferSystemTitlebar": "Preferir Barra de Título do Sistema", "enableSystemTitlebar": "Ativar barra de título do sistema",
"trafficGraph": "Gráfico de Tráfego", "trafficGraph": "Gráfico de Tráfego",
"memoryUsage": "Uso do Núcleo", "memoryUsage": "Uso do Núcleo",
"proxyGroupIcon": "Ícone do Grupo de Proxy", "proxyGroupIcon": "Ícone do Grupo de Proxy",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Pausar a renderização das estatísticas de tráfego quando sem foco" "pauseRenderTrafficStatsOnBlur": "Pausar a renderização das estatísticas de tráfego quando sem foco"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Usar a barra de título nativa do sistema em vez da barra de título personalizada integrada (é necessário reiniciar no Linux).",
"hoverNavigator": "Rola automaticamente até o grupo de proxy correspondente ao passar o mouse sobre as letras do alfabeto", "hoverNavigator": "Rola automaticamente até o grupo de proxy correspondente ao passar o mouse sobre as letras do alfabeto",
"hoverNavigatorDelay": "Atraso antes da rolagem automática ao passar o mouse, em milissegundos" "hoverNavigatorDelay": "Atraso antes da rolagem automática ao passar o mouse, em milissegundos"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Цепочка прокси", "header": "Цепочка прокси",
"warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.", "warning": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"confirmEnable": "Понятно, включить",
"keepDisabled": "Оставить выключенным",
"empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.", "empty": "Цепочка прокси может значительно снизить скорость сети. Если у вас нет явной рабочей необходимости или вы не понимаете эту настройку, оставьте ее отключенной.",
"instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку", "instruction": "Нажимайте на узлы по порядку, чтобы добавить их в цепочку",
"minimumNodes": "Для цепочки требуется минимум 2 узла", "minimumNodes": "Для цепочки требуется минимум 2 узла",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Настройки раскладки", "title": "Настройки раскладки",
"fields": { "fields": {
"preferSystemTitlebar": "Использовать системную панель заголовка", "enableSystemTitlebar": "Включить системную панель заголовка",
"trafficGraph": "График трафика", "trafficGraph": "График трафика",
"memoryUsage": "Использование памяти", "memoryUsage": "Использование памяти",
"proxyGroupIcon": "Значок группы прокси", "proxyGroupIcon": "Значок группы прокси",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "При потере фокуса приостанавливать отрисовку статистики трафика" "pauseRenderTrafficStatsOnBlur": "При потере фокуса приостанавливать отрисовку статистики трафика"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Использовать системную панель заголовка вместо встроенной настраиваемой (в Linux требуется перезапуск).",
"hoverNavigator": "Автоматически прокручивает к группе прокси при наведении на буквы", "hoverNavigator": "Автоматически прокручивает к группе прокси при наведении на буквы",
"hoverNavigatorDelay": "Задержка перед прокруткой при наведении (в миллисекундах)" "hoverNavigatorDelay": "Задержка перед прокруткой при наведении (в миллисекундах)"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Zincir Vekil Yapılandırması", "header": "Zincir Vekil Yapılandırması",
"warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.", "warning": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"confirmEnable": "Anladım, etkinleştir",
"keepDisabled": "Devre dışı kalsın",
"empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.", "empty": "Zincir vekil ağ hızını belirgin biçimde düşürür. Açık bir ihtiyacınız yoksa ya da bu yapılandırmayı bilmiyorsanız devre dışı bırakın.",
"instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın", "instruction": "Vekil zincirine eklemek için düğümlere sırayla tıklayın",
"minimumNodes": "Zincir vekil en az 2 düğüm gerektirir", "minimumNodes": "Zincir vekil en az 2 düğüm gerektirir",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Düzen Ayarı", "title": "Düzen Ayarı",
"fields": { "fields": {
"preferSystemTitlebar": "Sistem Başlık Çubuğunu Tercih Et", "enableSystemTitlebar": "Sistem başlık çubuğunu etkinleştir",
"trafficGraph": "Trafik Grafiği", "trafficGraph": "Trafik Grafiği",
"memoryUsage": "Çekirdek Kullanımı", "memoryUsage": "Çekirdek Kullanımı",
"proxyGroupIcon": "Vekil Grup Simgesi", "proxyGroupIcon": "Vekil Grup Simgesi",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Odak kaybolduğunda trafik istatistiklerinin işlenmesini duraklat" "pauseRenderTrafficStatsOnBlur": "Odak kaybolduğunda trafik istatistiklerinin işlenmesini duraklat"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Yerleşik özel başlık çubuğu yerine sistemin yerel başlık çubuğunu kullan (Linux'ta yeniden başlatma gerekir).",
"hoverNavigator": "Alfabe harflerinin üzerine gelindiğinde ilgili vekil grubuna otomatik olarak kaydırır", "hoverNavigator": "Alfabe harflerinin üzerine gelindiğinde ilgili vekil grubuna otomatik olarak kaydırır",
"hoverNavigatorDelay": "Üzerine gelindiğinde otomatik kaydırmadan önceki gecikme, milisaniye cinsinden" "hoverNavigatorDelay": "Üzerine gelindiğinde otomatik kaydırmadan önceki gecikme, milisaniye cinsinden"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "Chain Proxy Config", "header": "Chain Proxy Config",
"warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "warning": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"confirmEnable": "I understand, enable",
"keepDisabled": "Keep disabled",
"empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.", "empty": "Chain proxy will significantly reduce network speed. If you do not have a clear business need, or do not understand this configuration, keep it disabled.",
"instruction": "Click nodes in order to add to proxy chain", "instruction": "Click nodes in order to add to proxy chain",
"minimumNodes": "Chain proxy requires at least 2 nodes", "minimumNodes": "Chain proxy requires at least 2 nodes",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "Расположение көйләүләре", "title": "Расположение көйләүләре",
"fields": { "fields": {
"preferSystemTitlebar": "Prefer System Titlebar", "enableSystemTitlebar": "Enable System Titlebar",
"trafficGraph": "Трафик графигы", "trafficGraph": "Трафик графигы",
"memoryUsage": "Хәтер куллану", "memoryUsage": "Хәтер куллану",
"proxyGroupIcon": "Прокси төркеме иконкасы", "proxyGroupIcon": "Прокси төркеме иконкасы",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "Фокус югалганда трафик статистикасын сызуны туктатып тору" "pauseRenderTrafficStatsOnBlur": "Фокус югалганда трафик статистикасын сызуны туктатып тору"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "Use the system's native title bar instead of the built-in custom title bar (restart required on Linux).",
"hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters", "hoverNavigator": "Automatically scroll to the corresponding proxy group when hovering over alphabet letters",
"hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds" "hoverNavigatorDelay": "Delay before auto scrolling when hovering, in milliseconds"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "代理链配置", "header": "代理链配置",
"warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭", "warning": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"confirmEnable": "我明白,开启",
"keepDisabled": "保持关闭",
"empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭", "empty": "链式代理会显著降低网速。如果您没有明确的业务需求,或不了解此项配置,请保持关闭",
"instruction": "顺序点击节点添加到代理链中", "instruction": "顺序点击节点添加到代理链中",
"minimumNodes": "链式代理至少需要 2 个节点", "minimumNodes": "链式代理至少需要 2 个节点",
+2 -1
View File
@@ -231,7 +231,7 @@
"layout": { "layout": {
"title": "界面设置", "title": "界面设置",
"fields": { "fields": {
"preferSystemTitlebar": "优先使用系统标题栏", "enableSystemTitlebar": "启用系统标题栏",
"trafficGraph": "流量图显", "trafficGraph": "流量图显",
"memoryUsage": "内核占用", "memoryUsage": "内核占用",
"proxyGroupIcon": "代理组图标", "proxyGroupIcon": "代理组图标",
@@ -251,6 +251,7 @@
"pauseRenderTrafficStatsOnBlur": "失去焦点时暂停渲染流量统计" "pauseRenderTrafficStatsOnBlur": "失去焦点时暂停渲染流量统计"
}, },
"tooltips": { "tooltips": {
"enableSystemTitlebar": "使用系统原生标题栏替代内置的自定义标题栏(Linux 下需重启生效)",
"hoverNavigator": "鼠标悬停在字母上时自动滚动到对应代理组", "hoverNavigator": "鼠标悬停在字母上时自动滚动到对应代理组",
"hoverNavigatorDelay": "鼠标悬停后触发自动跳转前等待的毫秒数" "hoverNavigatorDelay": "鼠标悬停后触发自动跳转前等待的毫秒数"
}, },
-2
View File
@@ -70,8 +70,6 @@
"chain": { "chain": {
"header": "鏈式代理設定", "header": "鏈式代理設定",
"warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉", "warning": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"confirmEnable": "我明白,開啟",
"keepDisabled": "保持關閉",
"empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉", "empty": "鏈式代理會顯著降低網速。如果您沒有明確的業務需求,或不了解此項設定,請保持關閉",
"instruction": "依序點擊節點新增到鏈式代理中", "instruction": "依序點擊節點新增到鏈式代理中",
"minimumNodes": "鏈式代理至少需要 2 個節點", "minimumNodes": "鏈式代理至少需要 2 個節點",

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